mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(daemon): build the managed-MCP config from `config get mcp --json`
Preparing a task for an OpenClaw agent with a managed `mcp_config` reads the
user's resolved configuration so the wrapper can replace `mcp.servers` while
keeping the user's other `mcp.*` settings. That read asks for the resolved root,
`openclaw config get --json` with no key path, and current releases do not
answer it: measured against 2026.5.27 the CLI writes zero bytes to stdout and
never exits, and the same shape is reported against 2026.7.1-2. Every
managed-MCP task on such a host therefore fails preparation.
`config get mcp --json` is answered, and it is also the only part of the
configuration this code needs. Switching to it means the snapshot can no longer
be a flat copy of the user's resolved config, so it becomes an include chain:
1. `$include` the user's live config,
2. merge `{"mcp": null}`, which OpenClaw's include merge treats as a
wholesale replacement of the user's MCP object,
3. restore the resolved non-server MCP settings.
The wrapper then contributes the managed server set, which is the only server
definition the chain can yield. `sessionIdleTtlMs` and its siblings still
resolve; user-only servers do not.
Security posture moves in both directions, so both are worth stating:
- Better: the task directory no longer receives a copy of the user's resolved
configuration. That copy was a flat snapshot of everything the loader
produced, which the code it replaces acknowledged could carry API keys and
model-provider tokens, mitigated only by 0o600. The chain writes include paths
and MCP tuning, and nothing else.
- Worse: because the chain reaches out to the user's real config, the daemon has
to grant its directory through OPENCLAW_INCLUDE_ROOTS. The flat copy was
self-contained and needed no grant. The trade is a read grant on one directory
instead of duplicating that directory's secrets into per-task state, which
also means the include never goes stale relative to the user's config.
The MCP read uses the same deadline as every other call in the preparation
rather than a shorter budget of its own. It is one of the
openclawMaxCLICallsPerPreparation calls the budget arithmetic already accounts
for, it is the same class of invocation as the discovery calls — measured at
4.3s on the slow host in #7142 — and it has to honour
MULTICA_OPENCLAW_CLI_TIMEOUT for the same reason those do. A shorter private
budget would fail closed on exactly the machines that raise the override because
they need it.
`openclawResolvedFullConfig` and `stripUserMcpServers` lose their only caller.
The discovery cache's note about deliberately not caching the resolved config is
updated: the reason is no longer that the payload holds secrets, but that the
MCP subtree is per-agent state that has to reflect the user's configuration as
it is now.
Tests: the three strict-replace tests move to the MCP subtree and assert the
chain rather than a copy, including that the snapshot contains none of the
user's server bytes. TestPrepareOpenclawConfigIncludeRootGrants covers the grant
for all three include targets, because getting it wrong fails at OpenClaw's
include-confinement check with nothing pointing back here. The real-CLI test
behind the agentintegration tag asks the installed binary to resolve the
generated chain, since the correctness of the reset bridge lives in OpenClaw's
merge semantics rather than in the JSON this package writes.
* fix(ci): compile the agentintegration tests, and correct the snapshot doc
Two review follow-ups on the managed-MCP include chain.
The `openclawUserSnapshotFile` comment still described the flat sanitized
copy this PR replaced — "the user's config minus the `mcp` block". The file
now holds no user config bytes at all, only an include chain, which is also
why its 0o600 is about the paths it names rather than secrets it carries.
The name stays because the wrapper's include target is not worth churning
for a rename; the comment says why.
The design's load-bearing claim — that `{"mcp": null}` makes OpenClaw's
include merge replace the user's MCP object rather than merge into it —
is verified by openclaw_mcp_real_integration_test.go, and nothing compiled
that file. `agentintegration` guards nine files across the module and no CI
job referenced the tag, so a helper renamed in an ordinary test file, or two
branches each defining one in the same package, breaks the tagged build with
every untagged job still green. That is not hypothetical here: #6275 adds
openclaw_real_integration_test.go to this package with its own
`realOpenclawBin`, and dropping it in beside this one leaves `go build ./...`
passing while the new step reports
vet: openclaw_real_integration_test.go:18:6: realOpenclawBin redeclared
vet type-checks test files, so the gate needs no agent CLI and no runner
that can execute one. It sits next to Build, before the suite that would
otherwise be the first thing to notice.
* docs(execenv): scope the root-read claim to what was actually measured
Two comments asserted that "current releases" do not answer
`config get --json`, citing a report against 2026.7.1-2 as corroboration. The
measurement behind them is one host on 2026.5.27, and the citation does not
support the claim: the 2026.7.1-2 reports (@kaverjody in #6275, #7308) are both
about `openclaw config file` — cold-start timings and a descendant holding
stdout — not about the root read. #7308 in fact measures `config file` exiting
cleanly in 4.2s on that version.
Which command is which is the whole premise of this change: `config get mcp
--json` is answered while the root is not, so treating an observation of one as
evidence about the other undercuts the argument it was meant to support.
Scoped both comments to the measurement, and stated that the root read's
behaviour on 2026.7.1-2 is unmeasured. The decision does not need the broader
claim: the subtree is the only part of the configuration this code needs, and
the fail-open branch being removed is a silent-degradation bug on its own terms,
reachable from any cause that makes the read come back empty.
* fix(daemon): harden OpenClaw MCP isolation
* fix(execenv): correct the root-read diagnosis against measured channels
Measured 2026-08-26 on Windows 10.0.19045.6466 against the three npm channels.
Two claims in this branch were wrong, and the corrected version is a stronger
argument for the change rather than a weaker one.
**The root read does not hang; it is not a valid invocation.** `config get`
requires a path, and the pathless form is rejected outright:
2026.6.34 (extended-stable) exit 1 in 2.9s Missing required argument "path".
2026.7.1-2 (latest) exit 1 in 3.0s same message
2026.8.1-beta.3 (beta) exit 1 in 4.4s same message, JSON envelope
So `openclawResolvedFullConfig` did not degrade on some hosts — it failed
preparation for every managed-MCP task on all three channels shipping today.
2026.5.27 writing nothing and never exiting within 60s is one version differing
from every current one, and is now recorded as that rather than as the general
case. Switching to the subtree stops being a workaround for a hang and becomes
the request this code should always have made.
**The "exits 0 with empty stdout" shape is unobserved.** It needs exit 0; all
three channels exit 1. The fail-open branch removal still stands on its own —
it is a silent-degradation path and the new chain has no equivalent — but it is
no longer offered as the mechanism behind the reported `Unknown model`, which
remains a real symptom with an unestablished cause.
Also corrected from the same measurements:
- The missing-key transports are not a migration. 2026.6.34 and 2026.7.1-2 are
stderr-only with empty stdout; only 2026.7.2-beta.7 onward puts the envelope
on stdout. Both paths are load-bearing for channels shipping today, which the
detector doc now states with the version table.
- `config get agents.list --json` on 2026.6.34 and 2026.7.1-2 emits
"Config path not found: agents.list" and names the path, so requiring the
diagnostic to name the requested key does not regress the registry fallback on
current stable. `agents list --json` answers on all three.
- 2026.8.1-beta.3's `mcp` schema is exactly `servers` + `apps` with
additionalProperties:false; `sessionIdleTtlMs` is gone. Stage 3 of the chain
can only carry keys the same CLI just returned, which is what keeps it safe
against that constraint. Comments now use `mcp.apps` as the current example.
New regressions, all from verbatim measured output:
- the pathless usage error must not read as a missing key, in both the stderr
and the JSON-envelope form. This is the shape that actually occurs, and
misreading it would turn a broken invocation into a silent "user has no mcp
block";
- beta.3's invalid-config envelope (schema violation in a sibling `issues`
array) must not read as a missing key either.
The smoke workflow now fails when the test skips. A skipped Go test reports `ok`
at the package level, so the job would have gone green whenever the CLI was
absent or the -run pattern stopped matching — green exactly when nothing was
verified. #6897's Windows step documents the same hazard.
The real-CLI test's doc records the platform, the three resolved versions and
their PASS durations (36.9s / 40.6s / 34.2s), and the hand-run control: dropping
the reset stage leaks `user-only` back into the resolved server map on all three
channels, which is what makes those passes mean something.
* test(execenv): pin the reset-stage/wrapper-mcp pairing the loader depends on
The reset stage is only safe because the wrapper always contributes its
own mcp object under the same condition: OpenClaw merges sibling keys
over the include result, and a null that instead survives to the
resolved root fails schema validation (zod .strict().optional() rejects
null), taking the whole config load down. The pairing held by
construction but nothing named it; this assertion fails in the PR that
breaks it, instead of a week later in the scheduled smoke.
Co-Authored-By: Claude Fable 5 <[email protected]>
* docs(execenv): match the deadline-budget arithmetic to the constant
openclawMaxCLIDeadlinesPerPreparation dropped from 4 to 3 when the
managed-MCP read step was removed, but the worst-case explanation on
openclawCLIMaxTimeout still said 4 x 60s = 4m. Say 3 x 60s = 3m so the
documented safety arithmetic matches the constant and its test.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: weiweiwei <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
334 lines
13 KiB
Go
334 lines
13 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.
|
|
openclawDiscoveryCacheVersion = 1
|
|
|
|
// 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 `agents.list` array, stored verbatim so a hit
|
|
// reproduces exactly what the CLI returned.
|
|
AgentsList json.RawMessage `json:"agents_list"`
|
|
AgentsFromRegistry bool `json:"agents_from_registry"`
|
|
}
|
|
|
|
// 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, agentsFromRegistry bool, 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,
|
|
AgentsFromRegistry: agentsFromRegistry,
|
|
}
|
|
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
|
|
}
|