Files
multica/server/internal/daemon/execenv/openclaw_config_cache_test.go
T
d2423aa35c MUL-7590: fix(execenv): support OpenClaw agents.entries per-agent workspaces (#8678)
* fix(execenv): pin per-agent workspaces under the OpenClaw agents.entries schema

OpenClaw 2026.8 retired the agents.list config path and reports it as
"Unknown config path: agents.list", moving the agents to a map keyed by agent
id under agents.entries. The per-task wrapper only ever wrote
agents.defaults.workspace, and an agents.entries.<id>.workspace entry outranks
it verbatim: on a host where adding a second agent pinned the gateway agent to
an absolute workspace, every task ran in that pinned directory instead of its
prepared workdir, so the workdir skills/ never loaded and the agent edited the
wrong tree.

Read agents.entries when agents.list is gone, pin every entry workspace to the
task workdir on write-back, and keep the registry fallback for the generations
that have neither config path. The stdout-envelope branch now matches the
path-aware wording, so a 2026.8 host that prints its envelope without exiting is
read instead of failed closed, and the entries probe shares the agents.list
deadline so the worst case stays three CLI deadlines.

Co-authored-by: multica-agent <[email protected]>

* test(execenv): cover the agents.entries probe, write-back and shared deadline

Adds the regressions the review of the earlier attempt asked for: the current
"Unknown config path" envelope with err == nil selects the entries probe and
never the registry, both the plain and the managed-MCP flows pin every
agents.entries.<id>.workspace to the task workdir, a valid-but-unset entries
path falls back to the registry, and an envelope naming an unrelated path still
fails closed.

The worst-case budget test now drives the five-invocation chain and pins the
config-schema pair to one shared deadline, so a fourth budget fails loudly.

Co-authored-by: multica-agent <[email protected]>

* fix(execenv): pin agents.entries workspaces without copying the entry

Review feedback on the write-back: `config get` redacts the config before it
returns the path it was asked for, so every sensitive per-agent field comes back
as `__OPENCLAW_REDACTED__`. The wrapper is a sibling of the user's config in the
$include merge and siblings win, so copying the resolved entry would write that
sentinel over the user's real secret — memory search API key, web fetch headers,
sandbox SSH credentials — for every task on the host, and nothing on OpenClaw's
load path restores it. `agents.entries` is an object keyed by id and objects
merge recursively, so `{"<id>": {"workspace": "<workdir>"}}` is enough to pin the
workspace while every other field still arrives from the user's own config.

The same reasoning drops the resolved payload from the row representation: an
entries row now carries the id marker alone, so no redacted copy of the user's
agent config is held in memory or written to the discovery cache file.

Since only the source still decides the write-back, it is now carried
explicitly (`openclawAgentsSource`: list / entries / registry) rather than as a
boolean that effectively meant "not agents.list", and the source is recorded in
the cache entry so a hit rebuilds the same wrapper. The cache shape version is
bumped, so an entry written by an older daemon is a clean miss.

Co-authored-by: multica-agent <[email protected]>

* test(execenv): assert the entries write-back emits workspace alone

Review feedback: the tests encoded the old contract (name / model carried into
the wrapper verbatim). They now assert the wrapper carries exactly one key per
entry — `workspace` — and that nothing else from the resolved payload reaches
the file OpenClaw reads.

The new regression drives the redaction shapes upstream's own redact-snapshot
suite covers (memory search apiKey, web fetch headers, sandbox SSH credentials)
through the entries probe of a real preparation, and fails if any of them ends
up in the wrapper: with the copying write-back restored it reports the sentinel
in `agents.entries.main`. The unit test for the write-back gate also covers a
half-marked list, which a quiet per-row filter would have pinned anyway.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: tianshuai1 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-22 16:22:28 +08:00

445 lines
16 KiB
Go

package execenv
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// openclawCacheFixture is one host: a real (fake) openclaw binary file, a real
// user config file, a cache directory, and a stubbed CLI that counts calls.
// The binary and config have to exist on disk because the cache fingerprint
// stats both — that is the whole invalidation mechanism.
type openclawCacheFixture struct {
bin string
configPath string
cacheDir string
stub *openclawCLIStub
}
func newOpenclawCacheFixture(t *testing.T) *openclawCacheFixture {
t.Helper()
dir := t.TempDir()
bin := filepath.Join(dir, "openclaw")
writeTestExecutable(t, bin, []byte("#!/bin/sh\nexit 0\n"))
configPath := filepath.Join(dir, "openclaw.json")
if err := os.WriteFile(configPath, []byte(`{ "agents": { "list": [] } }`), 0o600); err != nil {
t.Fatalf("write user config: %v", err)
}
stub := installOpenclawStub(t, map[string]openclawResponse{
"config file": {stdout: configPath + "\n"},
"config get agents.list --json": {stdout: `[{"id":"scout"}]`},
})
stub.bin = bin
return &openclawCacheFixture{
bin: bin,
configPath: configPath,
cacheDir: t.TempDir(),
stub: stub,
}
}
func (f *openclawCacheFixture) prep() OpenclawConfigPrep {
return OpenclawConfigPrep{OpenclawBin: f.bin, CacheDir: f.cacheDir}
}
// run executes one full preparation into a throwaway env root and returns how
// many CLI invocations it needed.
func (f *openclawCacheFixture) run(t *testing.T) int {
t.Helper()
before := len(f.stub.calls)
envRoot := t.TempDir()
workDir := filepath.Join(envRoot, "workdir")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
if _, err := prepareOpenclawConfig(envRoot, workDir, f.prep()); err != nil {
t.Fatalf("prepareOpenclawConfig: %v", err)
}
return len(f.stub.calls) - before
}
func (f *openclawCacheFixture) cachePath() string {
return filepath.Join(f.cacheDir, openclawDiscoveryCacheFile)
}
// TestOpenclawDiscoveryCacheServesSecondPreparation is the headline behaviour.
// Discovery is two serial CLI calls that cost ~12.7s on the host in #7112, and
// every task — including every chat message, which goes through Reuse — used
// to pay them again. Raising the deadline without this would trade a hard
// failure for a per-message stall.
func TestOpenclawDiscoveryCacheServesSecondPreparation(t *testing.T) {
f := newOpenclawCacheFixture(t)
if got := f.run(t); got != 2 {
t.Fatalf("cold preparation made %d CLI calls, want 2 (config file + agents.list)", got)
}
if got := f.run(t); got != 0 {
t.Errorf("warm preparation made %d CLI calls, want 0 (served from cache)", got)
}
}
// TestOpenclawDiscoveryCacheHitProducesSameWrapper guards the actual contract:
// a cache hit has to produce the byte-identical wrapper a live run would, or
// the speedup silently changes what OpenClaw loads.
func TestOpenclawDiscoveryCacheHitProducesSameWrapper(t *testing.T) {
f := newOpenclawCacheFixture(t)
read := func() []byte {
t.Helper()
envRoot := t.TempDir()
workDir := filepath.Join(envRoot, "workdir")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
result, err := prepareOpenclawConfig(envRoot, workDir, f.prep())
if err != nil {
t.Fatalf("prepareOpenclawConfig: %v", err)
}
raw, err := os.ReadFile(result.ConfigPath)
if err != nil {
t.Fatalf("read wrapper: %v", err)
}
// The workspace override is env-root specific; normalise it away so the
// comparison is about the discovered content, not the temp path.
return []byte(strings.ReplaceAll(string(raw), workDir, "<WORKDIR>"))
}
cold := read()
warm := read()
if string(cold) != string(warm) {
t.Errorf("cache hit produced a different wrapper\ncold: %s\nwarm: %s", cold, warm)
}
}
// TestOpenclawDiscoveryCacheInvalidatesOnConfigChange covers the refresh
// semantics Reuse already promised: a user who adds an agent or rotates a
// provider must not keep getting the stale list for the rest of the TTL.
func TestOpenclawDiscoveryCacheInvalidatesOnConfigChange(t *testing.T) {
f := newOpenclawCacheFixture(t)
f.run(t)
// Rewrite the active config with different content. Size changes, so this
// is caught even on filesystems with coarse mtime granularity.
if err := os.WriteFile(f.configPath, []byte(`{ "agents": { "list": [ { "id": "scout" } ] } }`), 0o600); err != nil {
t.Fatalf("rewrite user config: %v", err)
}
if got := f.run(t); got != 2 {
t.Errorf("preparation after a config edit made %d CLI calls, want 2 (cache must be invalid)", got)
}
}
// TestOpenclawDiscoveryCacheInvalidatesOnBinaryChange covers an openclaw
// upgrade in place: same path, new build, potentially a different active
// config or agents schema.
func TestOpenclawDiscoveryCacheInvalidatesOnBinaryChange(t *testing.T) {
f := newOpenclawCacheFixture(t)
f.run(t)
writeTestExecutable(t, f.bin, []byte("#!/bin/sh\n# upgraded\nexit 0\n"))
if got := f.run(t); got != 2 {
t.Errorf("preparation after a binary upgrade made %d CLI calls, want 2 (cache must be invalid)", got)
}
}
// TestOpenclawDiscoveryCacheInvalidatesOnEnvChange covers the pointers that
// move the active config without touching any file we stat: OpenClaw's own
// path environment.
func TestOpenclawDiscoveryCacheInvalidatesOnEnvChange(t *testing.T) {
f := newOpenclawCacheFixture(t)
f.run(t)
t.Setenv("OPENCLAW_STATE_DIR", filepath.Join(t.TempDir(), "state"))
if got := f.run(t); got != 2 {
t.Errorf("preparation after an OpenClaw env change made %d CLI calls, want 2 (cache must be invalid)", got)
}
}
// TestOpenclawDiscoveryCacheExpiresWithTTL covers the drift the fingerprint
// cannot see: a nested `$include` target edited in place, or an agent added
// through OpenClaw's own registry. Neither touches the top-level config, so
// the TTL is the only backstop.
func TestOpenclawDiscoveryCacheExpiresWithTTL(t *testing.T) {
f := newOpenclawCacheFixture(t)
f.run(t)
raw, err := os.ReadFile(f.cachePath())
if err != nil {
t.Fatalf("read cache: %v", err)
}
var entry openclawDiscoveryCacheEntry
if err := json.Unmarshal(raw, &entry); err != nil {
t.Fatalf("decode cache: %v", err)
}
entry.CachedAtNano = time.Now().Add(-openclawDiscoveryCacheTTL - time.Second).UnixNano()
aged, err := json.Marshal(entry)
if err != nil {
t.Fatalf("encode aged cache: %v", err)
}
if err := os.WriteFile(f.cachePath(), aged, 0o600); err != nil {
t.Fatalf("write aged cache: %v", err)
}
if got := f.run(t); got != 2 {
t.Errorf("preparation past the TTL made %d CLI calls, want 2 (entry must expire)", got)
}
}
// TestOpenclawDiscoveryCacheRejectsFutureEntry keeps a clock jump (or a
// hand-edited file) from pinning an entry that never ages out.
func TestOpenclawDiscoveryCacheRejectsFutureEntry(t *testing.T) {
f := newOpenclawCacheFixture(t)
f.run(t)
raw, err := os.ReadFile(f.cachePath())
if err != nil {
t.Fatalf("read cache: %v", err)
}
var entry openclawDiscoveryCacheEntry
if err := json.Unmarshal(raw, &entry); err != nil {
t.Fatalf("decode cache: %v", err)
}
entry.CachedAtNano = time.Now().Add(time.Hour).UnixNano()
future, err := json.Marshal(entry)
if err != nil {
t.Fatalf("encode future cache: %v", err)
}
if err := os.WriteFile(f.cachePath(), future, 0o600); err != nil {
t.Fatalf("write future cache: %v", err)
}
if got := f.run(t); got != 2 {
t.Errorf("preparation with a future-dated entry made %d CLI calls, want 2", got)
}
}
// TestOpenclawDiscoveryCacheIgnoresCorruptEntry covers the file a killed
// writer or a full disk can leave behind. A bad cache must degrade to slow,
// never to a failed task.
func TestOpenclawDiscoveryCacheIgnoresCorruptEntry(t *testing.T) {
for name, body := range map[string]string{
"truncated json": `{"fingerprint":{"version":1,"bin_pa`,
"empty file": ``,
"wrong shape": `["not", "an", "entry"]`,
"foreign version": `{"fingerprint":{"version":999},"cached_at_nano":1,` +
`"active_config_path":"/nope","agents_list":[]}`,
} {
t.Run(name, func(t *testing.T) {
f := newOpenclawCacheFixture(t)
if err := os.WriteFile(f.cachePath(), []byte(body), 0o600); err != nil {
t.Fatalf("write corrupt cache: %v", err)
}
if got := f.run(t); got != 2 {
t.Errorf("preparation with a corrupt cache made %d CLI calls, want 2 (must fall back to the CLI)", got)
}
// And the bad entry must be replaced, not left to poison every
// later task with the same fallback cost.
if got := f.run(t); got != 0 {
t.Errorf("preparation after recovery made %d CLI calls, want 0", got)
}
})
}
}
// TestOpenclawDiscoveryCacheSkipsFreshInstall pins the one state that must
// never be cached: "the user has no config yet" flips the moment they run
// OpenClaw's setup, and a cached miss would keep the next tasks running
// without their models and auth profiles.
func TestOpenclawDiscoveryCacheSkipsFreshInstall(t *testing.T) {
f := newOpenclawCacheFixture(t)
missingPath := filepath.Join(t.TempDir(), "openclaw.json")
f.stub.responses["config file"] = openclawResponse{stdout: missingPath + "\n"}
f.run(t)
if _, err := os.Stat(f.cachePath()); !errors.Is(err, os.ErrNotExist) {
t.Errorf("fresh install wrote a cache entry (stat err = %v); it must stay uncached", err)
}
}
// TestOpenclawDiscoveryCacheSkipsFailedDiscovery keeps a transient CLI failure
// from being replayed out of cache — including the timeout this change exists
// for, which would otherwise be served instantly for the rest of the TTL.
func TestOpenclawDiscoveryCacheSkipsFailedDiscovery(t *testing.T) {
f := newOpenclawCacheFixture(t)
f.stub.responses["config get agents.list --json"] = openclawResponse{err: errors.New("boom")}
envRoot := t.TempDir()
workDir := filepath.Join(envRoot, "workdir")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("mkdir workdir: %v", err)
}
if _, err := prepareOpenclawConfig(envRoot, workDir, f.prep()); err == nil {
t.Fatal("expected the agents.list failure to fail preparation")
}
if _, err := os.Stat(f.cachePath()); !errors.Is(err, os.ErrNotExist) {
t.Errorf("failed discovery wrote a cache entry (stat err = %v); failures must stay uncached", err)
}
}
// TestOpenclawDiscoveryCacheFutureDatedEntry pins both edges of the
// future-dating rule.
//
// Callers sample time.Now() once and pass it down, so on a daemon running
// several tasks at once a peer routinely commits an entry stamped after the
// reader's sample. That entry is completely valid — rejecting it just costs an
// extra discovery run, and it is what made the concurrency test below flake.
// A real clock jump still has to be caught, since an entry dated far ahead
// would otherwise never age out of its TTL.
func TestOpenclawDiscoveryCacheFutureDatedEntry(t *testing.T) {
for _, tc := range []struct {
name string
ahead time.Duration
want bool
}{
{"concurrent writer, microseconds ahead", 500 * time.Microsecond, true},
{"just inside the tolerance", openclawDiscoveryCacheFutureSkew - time.Millisecond, true},
{"clock jump, well past the tolerance", openclawDiscoveryCacheFutureSkew + time.Minute, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := newOpenclawCacheFixture(t)
readerNow := time.Now()
if err := storeOpenclawDiscoveryCache(f.cachePath(), f.bin, f.configPath, []any{map[string]any{"id": "scout"}}, openclawAgentsSourceList, readerNow.Add(tc.ahead)); err != nil {
t.Fatalf("store: %v", err)
}
if _, ok := loadOpenclawDiscoveryCache(f.cachePath(), f.bin, readerNow); ok != tc.want {
t.Fatalf("entry dated %v ahead of the reader: hit = %t, want %t", tc.ahead, ok, tc.want)
}
})
}
}
// TestOpenclawDiscoveryCacheConcurrentPreparations covers several tasks
// starting at once on the same daemon: the writes are atomic, so every task
// either sees a complete old entry or a complete new one, and none of them
// fails.
func TestOpenclawDiscoveryCacheConcurrentPreparations(t *testing.T) {
f := newOpenclawCacheFixture(t)
const workers = 8
var wg sync.WaitGroup
var mu sync.Mutex
var failures []error
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
envRoot := t.TempDir()
workDir := filepath.Join(envRoot, "workdir")
if err := os.MkdirAll(workDir, 0o755); err != nil {
mu.Lock()
failures = append(failures, err)
mu.Unlock()
return
}
// Each worker gets its own stub-free path into discovery: the
// shared stub is not goroutine-safe, so drive the cache directly
// with the same store/load pair preparation uses.
if err := storeOpenclawDiscoveryCache(f.cachePath(), f.bin, f.configPath, []any{map[string]any{"id": "scout"}}, openclawAgentsSourceList, time.Now()); err != nil {
mu.Lock()
failures = append(failures, err)
mu.Unlock()
return
}
if _, ok := loadOpenclawDiscoveryCache(f.cachePath(), f.bin, time.Now()); !ok {
mu.Lock()
failures = append(failures, errors.New("cache load failed under concurrency"))
mu.Unlock()
}
}()
}
wg.Wait()
for _, err := range failures {
t.Errorf("concurrent cache use: %v", err)
}
// No half-written temp files may survive a clean run.
entries, err := os.ReadDir(f.cacheDir)
if err != nil {
t.Fatalf("read cache dir: %v", err)
}
for _, entry := range entries {
if entry.Name() != openclawDiscoveryCacheFile {
t.Errorf("leftover file in cache dir: %s", entry.Name())
}
}
}
// TestOpenclawDiscoveryCacheDisabledWithoutDir pins the opt-out: with no cache
// directory (profile resolution failed), preparation still works and simply
// pays the CLI every time.
func TestOpenclawDiscoveryCacheDisabledWithoutDir(t *testing.T) {
f := newOpenclawCacheFixture(t)
f.cacheDir = ""
if got := f.run(t); got != 2 {
t.Fatalf("first preparation made %d CLI calls, want 2", got)
}
if got := f.run(t); got != 2 {
t.Errorf("second preparation made %d CLI calls, want 2 (caching must stay off)", got)
}
}
// TestOpenclawDiscoveryCacheStoresEmptyAgentsList covers Elon's must-fix 2.
// openclawResolvedAgentsList returns (nil, false, nil) for two legitimate
// successes — a config whose agents.list is null, and an empty 2026.6+
// registry. Treating a nil list as "incomplete, do not cache" left exactly
// those users paying full discovery on every message, which is the cost this
// change exists to remove.
func TestOpenclawDiscoveryCacheStoresEmptyAgentsList(t *testing.T) {
cases := map[string]map[string]openclawResponse{
"config agents.list is null": {
"config get agents.list --json": {stdout: "null"},
},
"registry is empty": {
"config get agents.list --json": {err: errors.New("Config path not found: agents.list")},
"agents list --json": {stdout: "[]"},
},
}
for name, responses := range cases {
t.Run(name, func(t *testing.T) {
f := newOpenclawCacheFixture(t)
for args, resp := range responses {
f.stub.responses[args] = resp
}
cold := f.run(t)
if cold == 0 {
t.Fatalf("cold preparation made no CLI calls, want a live discovery")
}
if got := f.run(t); got != 0 {
t.Errorf("warm preparation made %d CLI calls, want 0: an agent-less host must be cacheable too", got)
}
})
}
}
// TestOpenclawDiscoveryCacheInvalidatesOnLegacyEnvChange covers must-fix 3.
// openclawFallbackConfigCandidates still honours the CLAWDBOT_* names, so a
// user who repoints those and restarts the daemon must not keep getting the
// pre-switch config served out of a cache that never looked at them.
func TestOpenclawDiscoveryCacheInvalidatesOnLegacyEnvChange(t *testing.T) {
for _, envVar := range []string{"CLAWDBOT_CONFIG_PATH", "CLAWDBOT_STATE_DIR"} {
t.Run(envVar, func(t *testing.T) {
f := newOpenclawCacheFixture(t)
f.run(t)
t.Setenv(envVar, filepath.Join(t.TempDir(), "legacy"))
if got := f.run(t); got != 2 {
t.Errorf("preparation after %s changed made %d CLI calls, want 2 (cache must be invalid)", envVar, got)
}
})
}
}