Files
multica/server/internal/daemon/execenv/openclaw_config_cache.go
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

341 lines
14 KiB
Go

package execenv
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/multica-ai/multica/server/internal/cli"
)
// OpenClaw config discovery costs two serial CLI round-trips per task
// preparation in the common case (`config validate --json`, then
// `config get agents.list --json`; see openclawMaxCLIDeadlinesPerPreparation for
// the worst case). On a fast host that is ~1s total; on the Intel Mac in #7112
// it is ~12.7s, and it is paid again for every task — including every chat
// message, because Reuse runs the same preparation path as Prepare. Raising
// the deadline alone would turn a hard failure into a per-message stall, so
// the discovery result is cached.
//
// The cache lives on disk rather than in package memory on purpose:
// Prepare/Reuse run inside a one-shot preparation helper process
// (PrepareIsolated / ReuseIsolated), so an in-process cache would never
// survive to the next task.
//
// What is NOT cached, and no longer exists to cache: the resolved config read
// this flow used to make for a managed mcp_config. Managed MCP is now prepared
// from a reset stage this package writes, so there is no per-agent CLI payload on
// this path at all — one fewer thing whose staleness would have to be reasoned
// about, and one fewer copy of the user's configuration anywhere.
const (
// openclawDiscoveryCacheFile is the per-profile cache file name. It sits
// in the profile directory (~/.multica[/profiles/<name>]) so every task on
// this daemon shares one entry.
openclawDiscoveryCacheFile = "openclaw-discovery-cache.json"
// openclawDiscoveryCacheVersion guards the on-disk shape. A daemon that
// reads an entry written by a different version treats it as a miss.
// 2 — the stored rows changed shape: the source is recorded explicitly
// (`agents_source`) and an `agents.entries` row is the id marker alone, not a
// copy of the redacted entry (see openclawResolvedAgentsEntriesOrRegistry).
openclawDiscoveryCacheVersion = 2
// openclawDiscoveryCacheTTL bounds how long a hit can be served. The
// fingerprint catches the changes we can observe cheaply (binary swap,
// OpenClaw path env, active config rewrite), but not the ones we cannot:
// a nested `$include` target edited in place, or an agent added through
// OpenClaw's own SQLite registry, neither of which touches the top-level
// config file. The TTL is the backstop that keeps those visible, at the
// cost of re-running discovery at most once a minute.
openclawDiscoveryCacheTTL = time.Minute
// openclawDiscoveryCacheFutureSkew is how far ahead of the reader's clock
// sample an entry may be dated and still count as fresh.
//
// Without it, tasks starting together on one daemon evict each other's
// work. Every caller samples time.Now() once and passes it down, so a peer
// that sampled later but committed its rename first leaves an entry dated
// microseconds "in the future" — and the reader, whose own store just
// succeeded, throws it away and re-runs discovery for nothing. The same
// interleaving is what made TestOpenclawDiscoveryCacheConcurrentPreparations
// flake in CI.
//
// One second is far above that window and far below what the guard below
// exists to catch. It also bounds the cost of being wrong: an entry dated
// into the future outlives its TTL by at most this much.
openclawDiscoveryCacheFutureSkew = time.Second
)
// openclawDiscoveryFingerprint is the "is this entry still true?" evidence
// captured when discovery ran. Every field must match on lookup; anything
// unequal (or unstattable) is a miss and the CLI runs again.
type openclawDiscoveryFingerprint struct {
Version int `json:"version"`
// Binary identity: the resolved absolute path plus size/mtime, so an
// upgrade in place or a PATH change to a different install invalidates.
BinPath string `json:"bin_path"`
BinSize int64 `json:"bin_size"`
BinModTimeNano int64 `json:"bin_mod_time_nano"`
// Env is the fingerprint of the environment variables that can move
// OpenClaw's active config out from under us. Values are compared, not
// hashed: they are paths, not secrets.
Env string `json:"env"`
// Active config stat, keyed to the path discovery reported.
ConfigPath string `json:"config_path"`
ConfigSize int64 `json:"config_size"`
ConfigModTimeNano int64 `json:"config_mod_time_nano"`
}
// openclawDiscoveryCacheEntry is one cached successful discovery.
type openclawDiscoveryCacheEntry struct {
Fingerprint openclawDiscoveryFingerprint `json:"fingerprint"`
CachedAtNano int64 `json:"cached_at_nano"`
ActiveConfigPath string `json:"active_config_path"`
// AgentsList is the resolved per-agent rows, stored so a hit reproduces
// exactly what the source returned.
AgentsList json.RawMessage `json:"agents_list"`
// AgentsSource is the openclawAgentsSource those rows came from, which
// decides whether and how they may be written back into the wrapper. It is
// part of the cached evidence for the same reason the rows are: a hit has to
// rebuild the same wrapper the live run would have built.
AgentsSource string `json:"agents_source"`
}
// openclawDiscoveryEnvVars are the environment variables that change which
// config OpenClaw considers active. HOME / USERPROFILE are included because
// every default candidate path is derived from them, and the CLAWDBOT_* pair
// because openclawFallbackConfigCandidates still honours those legacy names —
// a cache that ignored them could keep serving the pre-switch config for a
// full TTL after the user pointed the daemon somewhere else.
var openclawDiscoveryEnvVars = []string{
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
"CLAWDBOT_CONFIG_PATH",
"CLAWDBOT_STATE_DIR",
"HOME",
"USERPROFILE",
}
func openclawDiscoveryEnvFingerprint() string {
parts := make([]string, 0, len(openclawDiscoveryEnvVars))
for _, key := range openclawDiscoveryEnvVars {
parts = append(parts, key+"="+os.Getenv(key))
}
return strings.Join(parts, "\x00")
}
// resolveOpenclawBinPath expands a bare command name through PATH so the
// fingerprint records the binary that actually ran, not the string "openclaw".
// A lookup failure returns the input unchanged; the stat below then fails and
// the caller takes the miss path.
func resolveOpenclawBinPath(bin string) string {
if bin == "" {
bin = "openclaw"
}
if strings.ContainsRune(bin, filepath.Separator) {
return bin
}
if resolved, err := exec.LookPath(bin); err == nil {
return resolved
}
return bin
}
// openclawProfileCacheDir resolves the directory this daemon profile shares
// across tasks (~/.multica, or ~/.multica/profiles/<name>). A resolution
// failure only disables caching — preparation still works, it just pays the
// CLI cost every time.
func openclawProfileCacheDir(profile string, logger *slog.Logger) string {
dir, err := cli.ProfileDir(profile)
if err != nil {
if logger != nil {
logger.Warn("execenv: openclaw discovery cache disabled; could not resolve profile dir",
"profile", profile, "error", err)
}
return ""
}
return dir
}
// openclawDiscoveryCachePath returns the cache file inside dir, or "" when
// caching is disabled (no directory configured).
func openclawDiscoveryCachePath(dir string) string {
if strings.TrimSpace(dir) == "" {
return ""
}
return filepath.Join(dir, openclawDiscoveryCacheFile)
}
// buildOpenclawDiscoveryFingerprint captures the current evidence for bin and
// configPath. It returns an error when either file cannot be stat'ed, which
// callers treat as "do not cache / do not trust the cache" rather than as a
// task failure.
func buildOpenclawDiscoveryFingerprint(bin, configPath string) (openclawDiscoveryFingerprint, error) {
binPath := resolveOpenclawBinPath(bin)
binInfo, err := os.Stat(binPath)
if err != nil {
return openclawDiscoveryFingerprint{}, fmt.Errorf("stat openclaw binary: %w", err)
}
cfgInfo, err := os.Stat(configPath)
if err != nil {
return openclawDiscoveryFingerprint{}, fmt.Errorf("stat openclaw active config: %w", err)
}
return openclawDiscoveryFingerprint{
Version: openclawDiscoveryCacheVersion,
BinPath: binPath,
BinSize: binInfo.Size(),
BinModTimeNano: binInfo.ModTime().UnixNano(),
Env: openclawDiscoveryEnvFingerprint(),
ConfigPath: configPath,
ConfigSize: cfgInfo.Size(),
ConfigModTimeNano: cfgInfo.ModTime().UnixNano(),
}, nil
}
// loadOpenclawDiscoveryCache returns a cached discovery for bin when one is
// present, structurally valid, still inside the TTL, and fingerprint-equal to
// the current on-disk state. Every other outcome — missing file, unreadable
// file, truncated JSON left by a killed writer, foreign version, moved binary,
// edited config — is a plain miss: the caller re-runs the CLI and stays
// fail-closed. Errors are never propagated, because a bad cache must degrade
// to "slow", never to "task failed".
func loadOpenclawDiscoveryCache(cachePath, bin string, now time.Time) (openclawDiscoveryCacheEntry, bool) {
if cachePath == "" {
return openclawDiscoveryCacheEntry{}, false
}
raw, err := os.ReadFile(cachePath)
if err != nil {
return openclawDiscoveryCacheEntry{}, false
}
var entry openclawDiscoveryCacheEntry
if err := json.Unmarshal(raw, &entry); err != nil {
return openclawDiscoveryCacheEntry{}, false
}
if entry.Fingerprint.Version != openclawDiscoveryCacheVersion {
return openclawDiscoveryCacheEntry{}, false
}
// len 0 means the field was absent or empty — a truncated write, not a
// stored result. A discovery with no agents is stored as the four bytes
// "null" and is a legitimate hit.
if entry.ActiveConfigPath == "" || len(entry.AgentsList) == 0 {
return openclawDiscoveryCacheEntry{}, false
}
if entry.CachedAtNano <= 0 {
return openclawDiscoveryCacheEntry{}, false
}
age := now.Sub(time.Unix(0, entry.CachedAtNano))
// An age well below zero means the entry was written in the future — a
// clock jump or a hand-edited file. Treat it as a miss rather than trusting
// it for a TTL that may never expire. Sub-second future dating is not that:
// it is the ordinary result of a concurrent writer on this same daemon
// (see openclawDiscoveryCacheFutureSkew), and rejecting it only buys an
// unnecessary discovery run.
if age < -openclawDiscoveryCacheFutureSkew || age > openclawDiscoveryCacheTTL {
return openclawDiscoveryCacheEntry{}, false
}
current, err := buildOpenclawDiscoveryFingerprint(bin, entry.ActiveConfigPath)
if err != nil {
return openclawDiscoveryCacheEntry{}, false
}
if current != entry.Fingerprint {
return openclawDiscoveryCacheEntry{}, false
}
return entry, true
}
// storeOpenclawDiscoveryCache records a successful discovery. Only complete
// successes reach here: a fresh install with no config on disk, a CLI error,
// and a timeout all skip the write, so a transient failure can never be
// replayed from cache for the rest of the TTL.
//
// A nil agentsList is a success, not an omission: openclawResolvedAgentsList
// returns (nil, false, nil) for a config whose agents.list is null and for an
// empty 2026.6+ registry. Treating that as "incomplete" would leave exactly
// those users paying full discovery on every message, so it is stored as JSON
// null and decodes back to nil.
//
// The write is atomic (temp file + rename) so a concurrently reading task sees
// either the old entry or the new one, never a half-written file. Two tasks
// racing to store simply leave the later winner in place — both entries are
// equally valid.
func storeOpenclawDiscoveryCache(cachePath, bin, activeConfigPath string, agentsList []any, agentsSource openclawAgentsSource, now time.Time) error {
if cachePath == "" {
return nil
}
if activeConfigPath == "" {
return nil
}
fingerprint, err := buildOpenclawDiscoveryFingerprint(bin, activeConfigPath)
if err != nil {
return err
}
listBytes, err := json.Marshal(agentsList)
if err != nil {
return fmt.Errorf("marshal openclaw agents list: %w", err)
}
entry := openclawDiscoveryCacheEntry{
Fingerprint: fingerprint,
CachedAtNano: now.UnixNano(),
ActiveConfigPath: activeConfigPath,
AgentsList: listBytes,
AgentsSource: string(agentsSource),
}
data, err := json.Marshal(entry)
if err != nil {
return fmt.Errorf("marshal openclaw discovery cache: %w", err)
}
dir := filepath.Dir(cachePath)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create openclaw discovery cache dir: %w", err)
}
tmp, err := os.CreateTemp(dir, openclawDiscoveryCacheFile+".*")
if err != nil {
return fmt.Errorf("create openclaw discovery cache temp: %w", err)
}
tmpName := tmp.Name()
defer func() {
// Best-effort cleanup of the temp file when the rename never happened.
_ = os.Remove(tmpName)
}()
// 0o600 — the entry holds config paths and the user's agent ids, which is
// the same posture the per-task wrapper already uses.
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return fmt.Errorf("chmod openclaw discovery cache temp: %w", err)
}
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write openclaw discovery cache temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close openclaw discovery cache temp: %w", err)
}
if err := os.Rename(tmpName, cachePath); err != nil {
return fmt.Errorf("commit openclaw discovery cache: %w", err)
}
return nil
}
// decodeOpenclawCachedAgentsList converts a cached agents.list payload back
// into the []any shape prepareOpenclawConfig works with.
// A stored JSON null decodes to a nil slice, which is exactly the shape
// openclawResolvedAgentsList produces for "no agents", so a hit and a live run
// build the same wrapper.
func decodeOpenclawCachedAgentsList(raw json.RawMessage) ([]any, error) {
if len(raw) == 0 {
return nil, errors.New("empty cached agents list")
}
var list []any
if err := json.Unmarshal(raw, &list); err != nil {
return nil, err
}
return list, nil
}