mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* chore: clean historical comments (MUL-8034) Co-authored-by: multica-agent <[email protected]> * docs: preserve pending Composio expiry contract Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
1670 lines
73 KiB
Go
1670 lines
73 KiB
Go
package execenv
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/pkg/agent"
|
|
)
|
|
|
|
// openclawConfigFile is the per-task synthesized OpenClaw config the daemon
|
|
// points the openclaw CLI at via OPENCLAW_CONFIG_PATH. It sits in the env
|
|
// root (alongside workdir/, output/, logs/) so the GC reaper sweeps it with
|
|
// the rest of the task env.
|
|
const openclawConfigFile = "openclaw-config.json"
|
|
|
|
// openclawMcpResetFile holds exactly `{"mcp":{"servers":null}}` and is included
|
|
// after the user's live config whenever the agent has a managed mcp_config.
|
|
//
|
|
// One key, and nothing else, is the whole design. OpenClaw's include merge is
|
|
// recursive for objects and replacing for a primitive or null source, so nulling
|
|
// `mcp.servers` clears the user's server map while every sibling under `mcp`
|
|
// continues to arrive from the live config untouched — this code never reads,
|
|
// rewrites or re-emits them. That is what keeps a sibling this daemon has never
|
|
// heard of correct, and what makes it impossible to write a redacted display
|
|
// value back over a working one.
|
|
//
|
|
// The wrapper's own `mcp.servers` then merges over the include result and becomes
|
|
// the only server definition. Because the reset is written only when the wrapper
|
|
// carries that key, the null cannot survive to the resolved root, where
|
|
// OpenClaw's schema would reject it (zod `.strict().optional()` rejects null).
|
|
// TestPrepareOpenclawConfigResetStagePairsWithWrapperMcp pins that pairing.
|
|
//
|
|
// Lives in envRoot at 0o600 next to the wrapper. It carries no user data at all.
|
|
const openclawMcpResetFile = "openclaw-mcp-reset.json"
|
|
|
|
// openclawMcpResetBody is that file's entire content. A literal rather than a
|
|
// marshalled map: the point of this stage is that it names exactly one key, and a
|
|
// literal is the form a reader can check against the design in one glance.
|
|
const openclawMcpResetBody = "{\n \"mcp\": {\n \"servers\": null\n }\n}\n"
|
|
|
|
// openclawCLITimeout bounds each setup CLI call with headroom for slower hosts;
|
|
// MULTICA_OPENCLAW_CLI_TIMEOUT overrides it. The serial call budget must fit the
|
|
// outer preparation deadline.
|
|
//
|
|
// RunCollectQuiet owns pipes and process cleanup so descendants retaining stdout
|
|
// cannot extend the call indefinitely. Unix descendants that leave the process
|
|
// group may survive, but cannot extend the return bound. Early success requires
|
|
// an unambiguous completeness rule; path-shaped warning output is insufficient.
|
|
const openclawCLITimeout = 30 * time.Second
|
|
|
|
// OpenclawCLITimeoutEnv overrides openclawCLITimeout. Accepts a Go duration
|
|
// ("45s", "2m") or a bare number of seconds ("45"). Values outside
|
|
// [openclawCLIMinTimeout, openclawCLIMaxTimeout] are clamped, and anything
|
|
// unparseable is ignored, so a typo degrades to the default instead of
|
|
// disabling the deadline.
|
|
const OpenclawCLITimeoutEnv = "MULTICA_OPENCLAW_CLI_TIMEOUT"
|
|
|
|
const (
|
|
// openclawCLIMinTimeout keeps an override from being so small that no real
|
|
// CLI can answer; 1s is already below every measured healthy host.
|
|
openclawCLIMinTimeout = time.Second
|
|
// openclawCLIMaxTimeout keeps config discovery inside the outer task
|
|
// preparation budget (daemon.defaultTaskPrepareTimeout, 5 minutes). The
|
|
// worst case is openclawMaxCLIDeadlinesPerPreparation serial steps, so the
|
|
// ceiling is set so that even then (3 x 60s = 3m) the failure surfaces as a
|
|
// specific, actionable CLI timeout with room to spare, instead of colliding
|
|
// with the outer deadline and collapsing into the generic — and retryable —
|
|
// prepare-timeout reason. A step may make more than one invocation — path
|
|
// resolution falls back from `config validate --json` to `config file` under
|
|
// one shared deadline — which is why the multiplier counts deadlines.
|
|
openclawCLIMaxTimeout = 60 * time.Second
|
|
)
|
|
|
|
// openclawMaxCLIDeadlinesPerPreparation is how many CLI deadlines one task
|
|
// preparation can consume in the worst case. Deadlines, not invocations: each
|
|
// deadline bounds one *step*, and it is the sum of the steps that has to fit
|
|
// inside the outer preparation budget, so this is the multiplier
|
|
// openclawCLIMaxTimeout is derived from.
|
|
//
|
|
// The three steps, in the order they can fire:
|
|
//
|
|
// 1. locate the active config — `config validate --json`, then `config file`
|
|
// if that did not answer. Two invocations, one deadline: they ask the same
|
|
// question and openclawActiveConfigPath shares a context between them
|
|
// precisely so the fallback cannot add a fourth budget.
|
|
// 2. read the per-agent config the schema is willing to show —
|
|
// `config get agents.list --json` (pre-2026.6 `agents.list[]`), then
|
|
// `config get agents.entries --json` (2026.8+ `agents.entries.<id>`) when
|
|
// the first path is gone. Two invocations, one deadline, for the same
|
|
// reason as (1): both ask what the config schema carries per agent, and
|
|
// the second is only reached when the first reported no such path.
|
|
// 3. `agents list --json` — 2026.6+ registry fallback, only
|
|
// reached when (2) reports that neither config path exists
|
|
//
|
|
// It was four until managed MCP stopped reading the user's config: the reset
|
|
// stage is a file this package writes, so a managed-MCP agent now costs the same
|
|
// CLI time as any other. Adding a fourth deadline-bearing step means re-deriving
|
|
// the ceiling. The worst-case test counts distinct deadlines rather than calls,
|
|
// so a new invocation that shares an existing budget is free and one that brings
|
|
// its own fails loudly.
|
|
const openclawMaxCLIDeadlinesPerPreparation = 3
|
|
|
|
// ErrOpenclawCLITimeout marks a task preparation that failed because the local
|
|
// openclaw CLI did not answer within the deadline. It is a sentinel rather
|
|
// than a message-matched string for a reason: the daemon classifies on it
|
|
// structurally (taskRunFailureReason), and the old text-matching path routed
|
|
// every "deadline exceeded" into agent_error.provider_network — telling the
|
|
// user to check their network for a purely local stall, and auto-retrying a
|
|
// failure that is deterministic on the affected host.
|
|
//
|
|
// Preparation runs in a helper process, so the sentinel is re-attached on the
|
|
// daemon side of that boundary; see preparationErrorKindOpenclawCLITimeout.
|
|
var ErrOpenclawCLITimeout = errors.New("openclaw cli timeout")
|
|
|
|
// openclawCLITimeoutError carries the CLI's own diagnostic text while still
|
|
// matching both ErrOpenclawCLITimeout (our classifier) and the wrapped
|
|
// context error (callers that check cancellation the standard way).
|
|
type openclawCLITimeoutError struct {
|
|
msg string
|
|
cause error
|
|
}
|
|
|
|
func (e *openclawCLITimeoutError) Error() string { return e.msg }
|
|
|
|
func (e *openclawCLITimeoutError) Unwrap() []error {
|
|
return []error{e.cause, ErrOpenclawCLITimeout}
|
|
}
|
|
|
|
// resolveOpenclawCLITimeout picks the deadline for one CLI invocation:
|
|
// explicit (tests) > MULTICA_OPENCLAW_CLI_TIMEOUT > openclawCLITimeout.
|
|
func resolveOpenclawCLITimeout(explicit time.Duration, logger *slog.Logger) time.Duration {
|
|
if explicit > 0 {
|
|
return explicit
|
|
}
|
|
raw := strings.TrimSpace(os.Getenv(OpenclawCLITimeoutEnv))
|
|
if raw == "" {
|
|
return openclawCLITimeout
|
|
}
|
|
parsed, err := time.ParseDuration(raw)
|
|
if err != nil {
|
|
// Bare number means seconds. Parsed as a duration string rather than
|
|
// Atoi * time.Second, which overflows into a negative (or absurdly
|
|
// large) duration for big inputs instead of failing cleanly.
|
|
if secondsParsed, secErr := time.ParseDuration(raw + "s"); secErr == nil {
|
|
parsed, err = secondsParsed, nil
|
|
}
|
|
}
|
|
if err != nil || parsed <= 0 {
|
|
if logger != nil {
|
|
logger.Warn("execenv: ignoring unusable openclaw CLI timeout override; using default",
|
|
"env", OpenclawCLITimeoutEnv, "value", raw, "default", openclawCLITimeout)
|
|
}
|
|
return openclawCLITimeout
|
|
}
|
|
clamped := min(max(parsed, openclawCLIMinTimeout), openclawCLIMaxTimeout)
|
|
if clamped != parsed && logger != nil {
|
|
logger.Warn("execenv: clamping openclaw CLI timeout override",
|
|
"env", OpenclawCLITimeoutEnv, "value", raw, "applied", clamped)
|
|
}
|
|
return clamped
|
|
}
|
|
|
|
// OpenclawConfigPrep is the input to prepareOpenclawConfig. Only OpenclawBin
|
|
// and CacheDir are meaningful in production — Timeout is here for tests that
|
|
// need a tight deadline to assert error paths.
|
|
type OpenclawConfigPrep struct {
|
|
// OpenclawBin is the openclaw CLI binary to invoke for config introspection.
|
|
// Empty means resolve "openclaw" from PATH at exec time.
|
|
OpenclawBin string
|
|
// Timeout sets the context deadline for each CLI invocation — not a
|
|
// guaranteed cap on how long the call takes; see openclawCLITimeout. Zero
|
|
// falls back to the MULTICA_OPENCLAW_CLI_TIMEOUT override, then to
|
|
// openclawCLITimeout.
|
|
Timeout time.Duration
|
|
// CacheDir is the directory holding this daemon profile's shared
|
|
// discovery cache. Empty disables caching entirely — every task then pays
|
|
// the full CLI cost, which is correct but slow. See
|
|
// openclaw_config_cache.go for what is cached and how it is invalidated.
|
|
CacheDir string
|
|
// McpConfig is the agent's saved `mcp_config` JSON (Claude-style
|
|
// `{"mcpServers": {"<name>": {...}}}`). When non-null the wrapper pins
|
|
// `mcp.servers` to the managed set so OpenClaw resolves MCP from the
|
|
// daemon's authoritative list instead of the user's global `mcp.servers`.
|
|
// Null / empty means inherit the user's global config — same three-state
|
|
// semantics codex uses (`hasManagedCodexMcpConfig`).
|
|
McpConfig json.RawMessage
|
|
// Gateway pins a specific OpenClaw Gateway endpoint inside the per-task
|
|
// wrapper. Only consulted when the agent is configured for gateway-mode
|
|
// openclaw (see ExecOptions.OpenclawMode); zero means "inherit whatever
|
|
// the user's global openclaw.json already configures under `gateway.*`"
|
|
// — which is the right default when the user already has a working
|
|
// gateway set up locally. See issue #3260.
|
|
Gateway OpenclawGatewayPin
|
|
// Logger records the config-discovery outcome. Optional; nil disables
|
|
// logging. Discovery used to be entirely silent, which is why #6630 —
|
|
// a wrapper written without `$include` — could only be diagnosed by
|
|
// reading the generated file and reverse-engineering the daemon. Paths
|
|
// and booleans are logged; config contents never are.
|
|
Logger *slog.Logger
|
|
}
|
|
|
|
// OpenclawGatewayPin describes the Gateway endpoint a per-task openclaw
|
|
// wrapper should pin. Fields mirror OpenClaw's own `gateway.*` config shape
|
|
// (see ~/.openclaw/openclaw.json). All fields are optional; only non-zero
|
|
// fields are emitted into the wrapper so a partial pin (e.g. host+port
|
|
// only, token left to inherit from the user's config) does the right
|
|
// thing under OpenClaw's deep-merge $include semantics.
|
|
type OpenclawGatewayPin struct {
|
|
Host string `json:"host,omitempty"`
|
|
Port int `json:"port,omitempty"`
|
|
Token string `json:"token,omitempty"`
|
|
TLS bool `json:"tls,omitempty"`
|
|
}
|
|
|
|
// IsZero reports whether every field is zero, i.e. there is nothing to pin.
|
|
func (p OpenclawGatewayPin) IsZero() bool {
|
|
return p == OpenclawGatewayPin{}
|
|
}
|
|
|
|
// String masks the bearer token when the pin is rendered as a string —
|
|
// `%v` / `%+v` / direct `fmt.Stringer` use cases all go through here. The
|
|
// raw Token field still exists for the wrapper-config emitter that needs
|
|
// it; this is a belt against a future caller that logs a whole task-prep
|
|
// summary at a level a non-admin can see (issue #3260 CR).
|
|
func (p OpenclawGatewayPin) String() string {
|
|
tok := ""
|
|
if p.Token != "" {
|
|
tok = "***"
|
|
}
|
|
return fmt.Sprintf("OpenclawGatewayPin{Host:%q Port:%d Token:%s TLS:%t}", p.Host, p.Port, tok, p.TLS)
|
|
}
|
|
|
|
// MarshalJSON masks the bearer token in any default JSON dump (debug
|
|
// endpoints, error envelopes, structured-log encoders). The wrapper config
|
|
// writer goes through buildGatewayOverride, and the private preparation-helper
|
|
// transport uses its own methodless wire view, so both retain the real token.
|
|
func (p OpenclawGatewayPin) MarshalJSON() ([]byte, error) {
|
|
type alias struct {
|
|
Host string `json:"host,omitempty"`
|
|
Port int `json:"port,omitempty"`
|
|
Token string `json:"token,omitempty"`
|
|
TLS bool `json:"tls,omitempty"`
|
|
}
|
|
masked := alias{Host: p.Host, Port: p.Port, TLS: p.TLS}
|
|
if p.Token != "" {
|
|
masked.Token = "***"
|
|
}
|
|
return json.Marshal(masked)
|
|
}
|
|
|
|
// OpenclawConfigResult is what prepareOpenclawConfig returns to its callers
|
|
// in execenv.go. ConfigPath is the wrapper file the daemon points
|
|
// OPENCLAW_CONFIG_PATH at. IncludeRoot is the directory the daemon must add
|
|
// to OPENCLAW_INCLUDE_ROOTS so OpenClaw will follow the $include link out
|
|
// of envRoot into the user's active config; it is empty when no $include
|
|
// is emitted (fresh install).
|
|
type OpenclawConfigResult struct {
|
|
ConfigPath string
|
|
IncludeRoot string
|
|
}
|
|
|
|
// prepareOpenclawConfig writes a per-task wrapper and returns its config path
|
|
// and include root. OpenClaw's loader resolves JSON5, includes, and environment
|
|
// substitution; the original config is not rewritten.
|
|
//
|
|
// Override both the default and every agent-specific workspace so native skill
|
|
// discovery stays in the task workdir. Host-workspace SOUL.md and MEMORY.md are
|
|
// therefore excluded. Grant the active config's parent through OPENCLAW_INCLUDE_ROOTS
|
|
// because OpenClaw confines cross-directory includes.
|
|
//
|
|
// CLI, parse, and write failures abort preparation instead of silently losing user
|
|
// configuration. Only a confirmed missing config permits a minimal fresh-install
|
|
// wrapper.
|
|
func prepareOpenclawConfig(envRoot, workDir string, opts OpenclawConfigPrep) (OpenclawConfigResult, error) {
|
|
bin := opts.OpenclawBin
|
|
if bin == "" {
|
|
bin = "openclaw"
|
|
}
|
|
timeout := resolveOpenclawCLITimeout(opts.Timeout, opts.Logger)
|
|
|
|
activePath, exists, resolvedList, agentsSource, cached, err := discoverOpenclawConfig(bin, timeout, opts)
|
|
if err != nil {
|
|
return OpenclawConfigResult{}, err
|
|
}
|
|
if !exists && opts.Logger != nil {
|
|
// Not an error — a genuine fresh install lands here legitimately.
|
|
// But it is also where a failed discovery lands, and the two are
|
|
// indistinguishable from the outside, so say so loudly: every task
|
|
// prepared from this point runs without the user's model providers
|
|
// and auth profiles.
|
|
opts.Logger.Warn("execenv: openclaw active config not found; task wrapper will omit $include so the user's models and auth profiles will NOT be visible to this task",
|
|
"reported_path", activePath)
|
|
}
|
|
|
|
// Parse the agent's managed mcp_config (if any) before writing the wrapper
|
|
// so a malformed value fails the prepare step rather than crashing the
|
|
// openclaw subprocess later. Same fail-closed posture as Codex's
|
|
// ensureCodexMcpConfig — silent fallback to the user's global mcp.servers
|
|
// would be indistinguishable from "the managed set applied" and is exactly
|
|
// the surprise the MCP Tab is supposed to remove.
|
|
managedMcp, hasManagedMcp, err := openclawManagedMcpServers(opts.McpConfig)
|
|
if err != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("render openclaw mcp_config: %w", err)
|
|
}
|
|
|
|
// Replace managed MCP servers through ordered includes:
|
|
// live config -> mcp.servers: null -> managed servers.
|
|
//
|
|
// Deep merge alone would retain user-only servers, including for an empty managed
|
|
// set. Include the live config rather than snapshotting config-get output, which
|
|
// redacts secrets and cannot be round-tripped losslessly. OpenClaw's loader keeps
|
|
// MCP siblings, nested includes, environment substitution, and user config intact.
|
|
resetPath := ""
|
|
if hasManagedMcp && exists {
|
|
resetPath = filepath.Join(envRoot, openclawMcpResetFile)
|
|
if werr := os.WriteFile(resetPath, []byte(openclawMcpResetBody), 0o600); werr != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("write openclaw mcp reset: %w", werr)
|
|
}
|
|
}
|
|
|
|
cfg := buildPerTaskOpenclawConfig(activePath, exists, resetPath, resolvedList, agentsSource, workDir, managedMcp, hasManagedMcp, opts.Gateway)
|
|
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("marshal openclaw config: %w", err)
|
|
}
|
|
outPath := filepath.Join(envRoot, openclawConfigFile)
|
|
// 0o600 — defense in depth. The wrapper itself carries no secrets (the
|
|
// $include link is just a filesystem path), but the file lives next to
|
|
// task scratch and we keep the same posture as ~/.openclaw/openclaw.json.
|
|
if err := os.WriteFile(outPath, data, 0o600); err != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("write openclaw config: %w", err)
|
|
}
|
|
result := OpenclawConfigResult{ConfigPath: outPath}
|
|
includeTarget := "none"
|
|
if exists {
|
|
// Live user config is in its own directory; tell the daemon to grant
|
|
// it so OpenClaw's include-confinement check passes. The reset stage
|
|
// needs no grant of its own: it sits in envRoot beside the wrapper that
|
|
// includes it.
|
|
result.IncludeRoot = filepath.Dir(activePath)
|
|
includeTarget = "user-config"
|
|
if resetPath != "" {
|
|
includeTarget = "user-config+mcp-reset"
|
|
}
|
|
}
|
|
if opts.Logger != nil {
|
|
opts.Logger.Info("execenv: prepared openclaw config",
|
|
"active_config", activePath,
|
|
"active_config_exists", exists,
|
|
"include_target", includeTarget,
|
|
"include_root", result.IncludeRoot,
|
|
"agents_source", string(agentsSource),
|
|
"discovery_cached", cached,
|
|
"managed_mcp", hasManagedMcp)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// discoverOpenclawConfig resolves the user's active config path, the resolved
|
|
// per-agent rows and the schema they came from, serving all three from the
|
|
// shared per-profile cache when the cached evidence still matches the host (see
|
|
// openclaw_config_cache.go).
|
|
//
|
|
// The two are cached as one unit because they are read as one unit: a hit that
|
|
// covered only the path would still pay the second CLI call, which on the host
|
|
// in #7112 is 4.3s of the 12.7s total.
|
|
//
|
|
// Cache faults are never fatal. A miss, an unreadable entry, or a failed store
|
|
// only costs the CLI round-trips this call was going to make anyway, so
|
|
// discovery keeps its existing fail-closed contract: only a real CLI failure
|
|
// fails the task.
|
|
func discoverOpenclawConfig(bin string, timeout time.Duration, opts OpenclawConfigPrep) (activePath string, exists bool, resolvedList []any, agentsSource openclawAgentsSource, cached bool, err error) {
|
|
cachePath := openclawDiscoveryCachePath(opts.CacheDir)
|
|
if entry, ok := loadOpenclawDiscoveryCache(cachePath, bin, time.Now()); ok {
|
|
list, decodeErr := decodeOpenclawCachedAgentsList(entry.AgentsList)
|
|
if decodeErr == nil {
|
|
return entry.ActiveConfigPath, true, list, openclawAgentsSource(entry.AgentsSource), true, nil
|
|
}
|
|
if opts.Logger != nil {
|
|
opts.Logger.Warn("execenv: openclaw discovery cache entry unusable; rediscovering",
|
|
"cache", cachePath, "error", decodeErr)
|
|
}
|
|
}
|
|
|
|
activePath, exists, err = openclawActiveConfigPath(bin, timeout)
|
|
if err != nil {
|
|
return "", false, nil, "", false, fmt.Errorf("locate openclaw active config: %w", err)
|
|
}
|
|
if !exists {
|
|
// Deliberately not cached: "no config on disk" is the one state that
|
|
// flips the moment the user runs OpenClaw's own setup, and caching it
|
|
// would keep a freshly configured host running without its models and
|
|
// auth profiles for the rest of the TTL.
|
|
return activePath, false, nil, "", false, nil
|
|
}
|
|
|
|
resolvedList, agentsSource, err = openclawResolvedAgentsList(bin, timeout)
|
|
if err != nil {
|
|
return "", false, nil, "", false, fmt.Errorf("read openclaw agents.list: %w", err)
|
|
}
|
|
if storeErr := storeOpenclawDiscoveryCache(cachePath, bin, activePath, resolvedList, agentsSource, time.Now()); storeErr != nil && opts.Logger != nil {
|
|
opts.Logger.Warn("execenv: could not cache openclaw discovery; next task will rerun the CLI",
|
|
"cache", cachePath, "error", storeErr)
|
|
}
|
|
return activePath, exists, resolvedList, agentsSource, false, nil
|
|
}
|
|
|
|
// openclawAgentsSource names the OpenClaw surface the resolved per-agent rows
|
|
// were read from. It is what decides how — and whether — they may be written
|
|
// back into the per-task wrapper, so it is carried explicitly rather than
|
|
// inferred from a boolean: `agents.list[]` and `agents.entries.<id>` are config
|
|
// schemas this daemon can pin a per-agent `workspace` in, while registry rows
|
|
// (`agents list --json`) are read-only and must never be emitted as config.
|
|
type openclawAgentsSource string
|
|
|
|
const (
|
|
// openclawAgentsSourceList — pre-2026.6 config: `agents.list[]`.
|
|
openclawAgentsSourceList openclawAgentsSource = "list"
|
|
// openclawAgentsSourceEntries — 2026.8+ config: `agents.entries.<id>`.
|
|
openclawAgentsSourceEntries openclawAgentsSource = "entries"
|
|
// openclawAgentsSourceRegistry — the sqlite-backed registry, via
|
|
// `openclaw agents list --json`. Read-only.
|
|
openclawAgentsSourceRegistry openclawAgentsSource = "registry"
|
|
)
|
|
|
|
// buildPerTaskOpenclawConfig includes the active config and pins task workspaces.
|
|
// agents.list is replaced with resolved entries retaining every field except
|
|
// workspace. agents.entries overrides only workspace: config-get redacts sensitive
|
|
// fields, so copying whole entries would overwrite secrets with placeholders.
|
|
//
|
|
// For a confirmed missing config, emit only the task defaults. resetPath inserts
|
|
// an MCP-null stage after the active config; managed servers then merge over it.
|
|
// hasManagedMcp distinguishes an explicitly empty managed set from inheritance.
|
|
func buildPerTaskOpenclawConfig(activePath string, exists bool, resetPath string, resolvedList []any, agentsSource openclawAgentsSource, workDir string, managedMcp map[string]any, hasManagedMcp bool, gateway OpenclawGatewayPin) map[string]any {
|
|
agents := map[string]any{
|
|
"defaults": map[string]any{"workspace": workDir},
|
|
}
|
|
// Per-agent overrides are written back only into the schema the rows were
|
|
// actually read from (see openclawAgentsSource). A registry-sourced list
|
|
// (OpenClaw 2026.6.x/2026.7.x, `agents list --json`) is *not* config — the
|
|
// schema validator rejects its CLI-only fields ("agents.list.0: Invalid
|
|
// input") and fails closed before the agent runs — so those hosts get
|
|
// `agents.defaults.workspace` (set above) as their only per-task knob, which
|
|
// is sufficient there: OpenClaw applies it to the agent it selects from the
|
|
// registry (see upstream #3028, write-side half).
|
|
//
|
|
// 2026.8+ moved the agents out of `agents.list` into `agents.entries.<id>`,
|
|
// and that schema *is* writable: a host that pins an absolute per-agent
|
|
// `workspace` there outranks `agents.defaults.workspace` verbatim, so
|
|
// without this write-back every task on such a host runs in the gateway's
|
|
// own workspace instead of its prepared workdir (skills/ never loads).
|
|
switch agentsSource {
|
|
case openclawAgentsSourceList:
|
|
if rewritten := rewriteAgentsListWorkspaces(resolvedList, workDir); rewritten != nil {
|
|
agents["list"] = rewritten
|
|
}
|
|
case openclawAgentsSourceEntries:
|
|
if rewritten := rewriteAgentsEntriesWorkspaces(resolvedList, workDir); rewritten != nil {
|
|
agents["entries"] = rewritten
|
|
}
|
|
case openclawAgentsSourceRegistry, "":
|
|
// Read-only source (or nothing discovered): `agents.defaults.workspace`
|
|
// above is the whole per-task override.
|
|
}
|
|
cfg := map[string]any{
|
|
"agents": agents,
|
|
}
|
|
if hasManagedMcp {
|
|
// Always emit `mcp.servers` (even when empty) so the wrapper's intent
|
|
// — "admin manages this set" — is grep-able on disk and visible to
|
|
// OpenClaw's loader. The reset stage in the include list has already
|
|
// nulled the user's server map, so this becomes the only definition —
|
|
// and emitting it unconditionally here is what keeps that null from
|
|
// reaching the resolved root, where the schema would reject it.
|
|
servers := managedMcp
|
|
if servers == nil {
|
|
servers = map[string]any{}
|
|
}
|
|
cfg["mcp"] = map[string]any{"servers": servers}
|
|
}
|
|
// Gateway endpoint pin (issue #3260). Mirrors the user's openclaw.json
|
|
// `gateway.*` shape so OpenClaw's deep-merge $include semantics produce
|
|
// the right composed config: anything we set here wins over the user's
|
|
// global, anything we omit inherits from the user's global. Only emit
|
|
// fields the multica admin explicitly populated — zero strings/ints
|
|
// would override the user's value with junk.
|
|
if gw := buildGatewayOverride(gateway); gw != nil {
|
|
cfg["gateway"] = gw
|
|
}
|
|
if exists {
|
|
// Array form so OpenClaw deep-merges the included content with our
|
|
// sibling keys (agents overrides, mcp.servers) rather than letting the
|
|
// include replace the whole wrapper. With a managed set the reset stage
|
|
// follows the user's config in the same list, so the strict-replace flow
|
|
// costs one more entry rather than a second file and a nested include.
|
|
includes := []any{activePath}
|
|
if resetPath != "" {
|
|
includes = append(includes, resetPath)
|
|
}
|
|
cfg["$include"] = includes
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// buildGatewayOverride renders the non-zero subset of a Gateway pin into the
|
|
// shape OpenClaw expects under `gateway.*` (see ~/.openclaw/openclaw.json:
|
|
// host, port, tls at the top level and an `auth: {mode, token}` sub-object).
|
|
// Returns nil when nothing is populated so the caller can skip emission.
|
|
func buildGatewayOverride(p OpenclawGatewayPin) map[string]any {
|
|
if p.IsZero() {
|
|
return nil
|
|
}
|
|
out := map[string]any{}
|
|
if p.Host != "" {
|
|
out["host"] = p.Host
|
|
}
|
|
if p.Port != 0 {
|
|
out["port"] = p.Port
|
|
}
|
|
if p.TLS {
|
|
out["tls"] = true
|
|
}
|
|
if p.Token != "" {
|
|
out["auth"] = map[string]any{
|
|
"mode": "token",
|
|
"token": p.Token,
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rewriteAgentsListWorkspaces copies every entry of the resolved agents.list
|
|
// and pins its `workspace` field to workDir. Returns nil when the input is
|
|
// nil or empty so buildPerTaskOpenclawConfig can omit the key entirely
|
|
// (avoiding an empty `agents.list: []` that would replace whatever the
|
|
// include carries).
|
|
func rewriteAgentsListWorkspaces(list []any, workDir string) []any {
|
|
if len(list) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]any, 0, len(list))
|
|
for _, item := range list {
|
|
entry, ok := item.(map[string]any)
|
|
if !ok {
|
|
// Shape we don't recognize — skip rather than guess. Worst case
|
|
// the user loses native skill discovery on that one agent; we
|
|
// still won't crash the wrapper.
|
|
continue
|
|
}
|
|
copyEntry := make(map[string]any, len(entry)+1)
|
|
for k, v := range entry {
|
|
copyEntry[k] = v
|
|
}
|
|
copyEntry["workspace"] = workDir
|
|
out = append(out, copyEntry)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// The resolved `agents.entries` map is carried through this package as a
|
|
// `[]any` because that is the shape the discovery cache stores and re-reads for
|
|
// every schema (see openclaw_config_cache.go) and because a list is what the
|
|
// rest of the resolver contract speaks. The map key — the agent id — has no
|
|
// field of its own inside the entry object, so it rides along out of band on
|
|
// each row and is stripped again on the way into the wrapper: writing this key
|
|
// into the user's config would be a schema change we have no business making.
|
|
//
|
|
// Nothing else about an entry is carried: `config get` hands back redacted
|
|
// values (see rewriteAgentsEntriesWorkspaces), so keeping the payload around
|
|
// would only create copies — in the row, in the cache file — of data that must
|
|
// never be written back anyway.
|
|
const openclawEntriesKeyField = "__multica_entries_key"
|
|
|
|
// rewriteAgentsEntriesWorkspaces rebuilds the `agents.entries.<id>` map from a
|
|
// resolved entries list, pinning every entry's `workspace` to workDir.
|
|
//
|
|
// **Each entry carries `workspace` and nothing else.** The resolved entries
|
|
// cannot be copied through: `config get` redacts the config before extracting
|
|
// the path it was asked for, so any sensitive per-agent field (memory search
|
|
// API key, web fetch headers, sandbox SSH credentials, …) comes back as the
|
|
// literal `__OPENCLAW_REDACTED__` sentinel. `$include` resolves as
|
|
// deepMerge(included, siblings) with the wrapper's siblings winning, so copying
|
|
// the entry would write that sentinel over the user's real secret for every
|
|
// task, and nothing on OpenClaw's load path restores it. Emitting one key per id
|
|
// is both sufficient — objects merge recursively, so every other field still
|
|
// arrives from the user's own config verbatim — and safe.
|
|
//
|
|
// Returns nil unless *every* element carries the entries-probe key, so a list
|
|
// from any other source never reaches the wrapper as `agents.entries`: rows
|
|
// from the registry carry CLI-only fields OpenClaw's validator rejects
|
|
// ("agents.entries.main: Invalid input"), and on the generations that have a
|
|
// registry fallback the `agents.entries` path does not exist at all. An empty
|
|
// map is nil too, so the caller omits the key entirely rather than replacing
|
|
// whatever the $include carries with `entries: {}`.
|
|
func rewriteAgentsEntriesWorkspaces(list []any, workDir string) map[string]any {
|
|
entries := make(map[string]any, len(list))
|
|
for _, item := range list {
|
|
entry, ok := item.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
id, ok := entry[openclawEntriesKeyField].(string)
|
|
if !ok || id == "" {
|
|
return nil
|
|
}
|
|
entries[id] = map[string]any{"workspace": workDir}
|
|
}
|
|
if len(entries) == 0 {
|
|
return nil
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// openclawActiveConfigPath returns the active absolute path and its existence.
|
|
// Prefer config validate --json: its named path field is distinguishable from
|
|
// warnings, unlike the last-line output of config file. Missing or invalid configs
|
|
// may still return a usable structured path with a nonzero exit status.
|
|
//
|
|
// Delegate config/state/home and legacy-location resolution to the CLI. Fall back
|
|
// to active-config candidates only when the installed command shape is unsupported.
|
|
// Expand reported home shorthand before writing an absolute include reference.
|
|
func openclawActiveConfigPath(bin string, timeout time.Duration) (string, bool, error) {
|
|
// One deadline for the question, not one per attempt. Both invocations answer
|
|
// "where is the active config", so they are a single step of preparation with
|
|
// a preferred and a fallback way of asking — and the budget that has to hold
|
|
// is the step's. Giving the fallback a fresh full deadline made the worst case
|
|
// five deadlines against a ceiling derived from four: at the 60s override
|
|
// ceiling that is 5m of CLI time alone, landing exactly on
|
|
// daemon.defaultTaskPrepareTimeout, which collapses the specific
|
|
// non-retryable ErrOpenclawCLITimeout back into the generic retryable prepare
|
|
// timeout — the outcome openclawCLIMaxTimeout exists to prevent.
|
|
//
|
|
// The consequence is deliberate: a `config validate --json` that burns the
|
|
// whole budget leaves the fallback none, and openclawExec then fails
|
|
// immediately on the expired context — os/exec's Start reports it, and
|
|
// execOpenclawCLI attributes ctx first, so it still surfaces as
|
|
// ErrOpenclawCLITimeout. That is the right report. A CLI that cannot say where
|
|
// its config is within the entire deadline will not answer the same question
|
|
// on a second one; a fresh budget would only double the time to an identical
|
|
// conclusion, and spend it inside the outer preparation deadline.
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
if path, ok := openclawValidatedConfigPath(ctx, bin); ok {
|
|
return openclawStatConfigPath(path)
|
|
}
|
|
|
|
out, err := openclawExec(ctx, bin, "config", "file")
|
|
if err != nil {
|
|
if isOpenclawConfigFileUnsupported(err) {
|
|
path, exists, ferr := openclawFallbackActiveConfigPath()
|
|
if ferr != nil {
|
|
return "", false, fmt.Errorf("fallback after unsupported `openclaw config file` (%v): %w", err, ferr)
|
|
}
|
|
return path, exists, nil
|
|
}
|
|
return "", false, err
|
|
}
|
|
return openclawParseActiveConfigPath(out)
|
|
}
|
|
|
|
// openclawValidatedConfigPath asks `openclaw config validate --json` for the
|
|
// active config path, and reports whether the answer was unambiguous.
|
|
//
|
|
// It takes the caller's ctx rather than its own timeout: this attempt and the
|
|
// `config file` fallback share one deadline, because they are two ways of asking
|
|
// the same question. See openclawActiveConfigPath.
|
|
//
|
|
// The exit status is deliberately not consulted, and that is the common case
|
|
// rather than a corner: measured on 2026.7.1-2, upstream exits 1 both for a
|
|
// missing config file (`{"valid":false,"path":"…","error":"file not found"}`) and
|
|
// for an invalid one (`{"valid":false,"path":"…","issues":[…]}`), with stderr empty
|
|
// and the path present in both. A fresh install is the missing-file case, so
|
|
// reading a non-zero exit as "no answer" would break first run. Whether the user's
|
|
// config parses is not this function's question either — openclaw itself reports
|
|
// that when it runs; all that is owed here is where the file is.
|
|
//
|
|
// Failure is silent by design: every failure mode here is a reason to ask
|
|
// `config file` instead, and reporting one would turn "this CLI answered in a
|
|
// shape we do not understand" into a task failure. The only requirement is that
|
|
// the path be absolute after expansion, which is what distinguishes a real answer
|
|
// from the `CONFIG_PATH ?? "openclaw.json"` fallback upstream prints when it
|
|
// throws before reading the snapshot.
|
|
func openclawValidatedConfigPath(ctx context.Context, bin string) (string, bool) {
|
|
out, _ := openclawExec(ctx, bin, "config", "validate", "--json")
|
|
trimmed := strings.TrimSpace(out)
|
|
if trimmed == "" {
|
|
return "", false
|
|
}
|
|
var payload struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
|
return "", false
|
|
}
|
|
reported := strings.TrimSpace(payload.Path)
|
|
if reported == "" {
|
|
return "", false
|
|
}
|
|
// The reported value must already name an absolute location, before any
|
|
// expansion. Upstream prints `CONFIG_PATH ?? "openclaw.json"` when it throws
|
|
// before reading the snapshot, and expandOpenclawPath would turn that bare
|
|
// relative literal into a confident `<daemon cwd>/openclaw.json` — the #6630
|
|
// failure shape, arrived at from a different direction. The `~` and
|
|
// `$OPENCLAW_HOME` forms are absolute once resolved, so they are allowed
|
|
// through; anything else relative is not an answer, and `config file` gets
|
|
// asked instead.
|
|
if !filepath.IsAbs(reported) {
|
|
_, isTilde := openclawTildeRest(reported)
|
|
_, isHome := openclawHomeRest(reported)
|
|
if !isTilde && !isHome {
|
|
return "", false
|
|
}
|
|
}
|
|
expanded, err := expandOpenclawPath(reported)
|
|
if err != nil || !filepath.IsAbs(expanded) {
|
|
return "", false
|
|
}
|
|
return expanded, true
|
|
}
|
|
|
|
func openclawParseActiveConfigPath(out string) (string, bool, error) {
|
|
// OpenClaw may print terminal UI borders (e.g., Doctor warnings) before
|
|
// the actual path. The path is always the last non-empty line.
|
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
path := ""
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
trimmed := strings.TrimSpace(lines[i])
|
|
if trimmed != "" {
|
|
path = trimmed
|
|
break
|
|
}
|
|
}
|
|
if path == "" {
|
|
return "", false, fmt.Errorf("`openclaw config file` returned empty output")
|
|
}
|
|
var err error
|
|
path, err = expandOpenclawPath(path)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return openclawStatConfigPath(path)
|
|
}
|
|
|
|
func openclawFallbackActiveConfigPath() (string, bool, error) {
|
|
if explicitPath := strings.TrimSpace(os.Getenv("OPENCLAW_CONFIG_PATH")); explicitPath != "" {
|
|
path, err := expandOpenclawPath(explicitPath)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return openclawStatConfigPath(path)
|
|
}
|
|
|
|
candidates, canonicalPath, err := openclawFallbackConfigCandidates()
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
for _, candidate := range candidates {
|
|
path, err := expandOpenclawPath(candidate)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
exists, err := openclawConfigPathExists(path)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if exists {
|
|
return path, true, nil
|
|
}
|
|
}
|
|
return openclawStatConfigPath(canonicalPath)
|
|
}
|
|
|
|
var openclawFallbackConfigFileNames = []string{
|
|
"openclaw.json",
|
|
"clawdbot.json",
|
|
"moltbot.json",
|
|
"moldbot.json",
|
|
}
|
|
|
|
var openclawFallbackConfigDirNames = []string{
|
|
".openclaw",
|
|
".clawdbot",
|
|
".moltbot",
|
|
".moldbot",
|
|
}
|
|
|
|
func openclawFallbackConfigCandidates() ([]string, string, error) {
|
|
candidates := make([]string, 0, 1+2*len(openclawFallbackConfigFileNames)+len(openclawFallbackConfigDirNames)*len(openclawFallbackConfigFileNames))
|
|
for _, env := range []string{"CLAWDBOT_CONFIG_PATH"} {
|
|
if path := strings.TrimSpace(os.Getenv(env)); path != "" {
|
|
candidates = append(candidates, path)
|
|
}
|
|
}
|
|
|
|
for _, env := range []string{"OPENCLAW_STATE_DIR", "CLAWDBOT_STATE_DIR"} {
|
|
if dir := strings.TrimSpace(os.Getenv(env)); dir != "" {
|
|
candidates = appendOpenclawConfigFileCandidates(candidates, dir)
|
|
}
|
|
}
|
|
|
|
home := strings.TrimSpace(os.Getenv("OPENCLAW_HOME"))
|
|
var err error
|
|
if home == "" {
|
|
home, err = os.UserHomeDir()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("resolve openclaw home: %w", err)
|
|
}
|
|
} else {
|
|
home, err = expandOpenclawPath(home)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("resolve OPENCLAW_HOME: %w", err)
|
|
}
|
|
}
|
|
|
|
for _, dirName := range openclawFallbackConfigDirNames {
|
|
candidates = appendOpenclawConfigFileCandidates(candidates, filepath.Join(home, dirName))
|
|
}
|
|
return candidates, filepath.Join(home, ".openclaw", "openclaw.json"), nil
|
|
}
|
|
|
|
func appendOpenclawConfigFileCandidates(candidates []string, dir string) []string {
|
|
for _, name := range openclawFallbackConfigFileNames {
|
|
candidates = append(candidates, filepath.Join(dir, name))
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
// openclawTildeRest splits a `~`-shortened path into the part after the home
|
|
// prefix, reporting whether the path was tilde-shortened at all.
|
|
//
|
|
// The separator after `~` is whatever the CLI's host OS uses: OpenClaw
|
|
// prints `~/.openclaw/openclaw.json` on Unix and `~\.openclaw\openclaw.json`
|
|
// on Windows. Matching only the forward-slash form left the Windows tilde
|
|
// unexpanded, and since `~\...` is not absolute the path then got joined
|
|
// onto the daemon's working directory, producing a path that can never
|
|
// exist. The stat miss was indistinguishable from a fresh install, so the
|
|
// wrapper silently dropped the user's `$include` and every task booted
|
|
// without their model providers or auth profiles (issue #6630).
|
|
//
|
|
// Both separators are accepted regardless of runtime.GOOS, deliberately, and
|
|
// not via os.IsPathSeparator (which rejects `\` on Unix). The daemon and the
|
|
// CLI share a host, so only the host's own form arises in production — but
|
|
// keying on the character rather than the host OS lets the Windows shape be
|
|
// exercised from the normal Linux/macOS test job instead of only on a Windows
|
|
// runner, the same trade isOpenclawShimPath makes above.
|
|
func openclawTildeRest(path string) (string, bool) {
|
|
if path == "~" {
|
|
return "", true
|
|
}
|
|
if len(path) > 1 && path[0] == '~' && (path[1] == '/' || path[1] == '\\') {
|
|
return path[2:], true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// openclawHomeRest recognizes the symbolic shape current OpenClaw releases
|
|
// print when OPENCLAW_HOME is set: the variable name rather than its value, for
|
|
// example `$OPENCLAW_HOME\.openclaw\openclaw.json`. Nothing downstream expands
|
|
// it, so the line reaches filepath.Abs as an ordinary relative path and lands
|
|
// under the daemon's working directory — the same stat miss #6630 fixed for the
|
|
// tilde form, with the same consequence: indistinguishable from a fresh
|
|
// install, so the wrapper drops the user's `$include` and the task boots
|
|
// without their model providers or auth profiles.
|
|
//
|
|
// The shape is guaranteed rather than incidental. At `v2026.5.27`,
|
|
// `src/cli/config-cli.ts`'s `runConfigFile` prints `shortenHomePath(...)`, and
|
|
// `src/utils.ts:147-157` uses the `$OPENCLAW_HOME` prefix whenever that variable
|
|
// is non-empty and `~` otherwise — so setting the variable is what selects this
|
|
// form. Still true at `v2026.7.1`: `runConfigFile` is unchanged, and
|
|
// `resolveHomeDisplayPrefix` there returns `$OPENCLAW_HOME` on a non-empty
|
|
// trimmed `OPENCLAW_HOME` and `~` otherwise, with the separator coming from the
|
|
// original path rather than being inserted — which is why the rest is sliced
|
|
// after the prefix here rather than trimmed of a leading separator.
|
|
//
|
|
// Only the bare spelling is emitted by that code path. `${OPENCLAW_HOME}` is
|
|
// accepted as defense against a release that spells it the other way, not
|
|
// because anything is known to print it. Both separators are accepted
|
|
// regardless of runtime.GOOS, for the reason openclawTildeRest gives above.
|
|
func openclawHomeRest(path string) (string, bool) {
|
|
for _, prefix := range []string{"$OPENCLAW_HOME", "${OPENCLAW_HOME}"} {
|
|
if path == prefix {
|
|
return "", true
|
|
}
|
|
if len(path) > len(prefix) && strings.HasPrefix(path, prefix) &&
|
|
(path[len(prefix)] == '/' || path[len(prefix)] == '\\') {
|
|
return path[len(prefix)+1:], true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// openclawHomeFromEnv resolves OPENCLAW_HOME to the directory the CLI's printed
|
|
// `$OPENCLAW_HOME` prefix actually stands for.
|
|
//
|
|
// The value itself may be a tilde path. Upstream documents it
|
|
// (`docs/help/environment.md` at `v2026.5.27`: "OPENCLAW_HOME can also be set to
|
|
// a tilde path (e.g. `~/svc`), which gets expanded using the same OS home
|
|
// fallback chain before use") and implements it in `src/infra/home-dir.ts:41-47`.
|
|
// It expands the value *before* computing the home that `config file` then
|
|
// shortens, so on such a host the printed `$OPENCLAW_HOME` stands for
|
|
// `<os-home>/svc`, and landing on the same file means resolving it the same way.
|
|
// Joining the raw value instead leaves the `~` embedded, filepath.Abs turns that
|
|
// into a confident absolute path under the daemon's working directory, and the
|
|
// stat miss reproduces the silent fresh-install failure this whole branch exists
|
|
// to remove.
|
|
//
|
|
// Only the tilde is expanded here, deliberately — not the variable form — so a
|
|
// self-referential value cannot resolve against itself.
|
|
func openclawHomeFromEnv() (string, error) {
|
|
home := strings.TrimSpace(os.Getenv("OPENCLAW_HOME"))
|
|
if home == "" {
|
|
return "", errors.New("environment variable is empty")
|
|
}
|
|
rest, isTilde := openclawTildeRest(home)
|
|
if !isTilde {
|
|
return home, nil
|
|
}
|
|
osHome, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("expand `~` in OPENCLAW_HOME: %w", err)
|
|
}
|
|
if rest == "" {
|
|
return osHome, nil
|
|
}
|
|
return filepath.Join(osHome, rest), nil
|
|
}
|
|
|
|
func expandOpenclawPath(path string) (string, error) {
|
|
// OPENCLAW_HOME before `~`: the two shapes are mutually exclusive, but the
|
|
// variable form is checked first because an empty OPENCLAW_HOME has to fail
|
|
// loudly rather than fall through to a relative-path resolution that would
|
|
// quietly produce a wrong absolute path.
|
|
if rest, isOpenclawHome := openclawHomeRest(path); isOpenclawHome {
|
|
home, herr := openclawHomeFromEnv()
|
|
if herr != nil {
|
|
return "", fmt.Errorf("expand OPENCLAW_HOME in openclaw config path %q: %w", path, herr)
|
|
}
|
|
if rest == "" {
|
|
path = home
|
|
} else {
|
|
path = filepath.Join(home, rest)
|
|
}
|
|
} else if rest, isTilde := openclawTildeRest(path); isTilde {
|
|
home, herr := os.UserHomeDir()
|
|
if herr != nil {
|
|
return "", fmt.Errorf("expand `~` in openclaw config path %q: %w", path, herr)
|
|
}
|
|
if rest == "" {
|
|
path = home
|
|
} else {
|
|
// The remainder still carries the CLI's separators. filepath.Join
|
|
// normalizes them to the host's on the OS that matters here
|
|
// (Windows accepts both), and the result is what we stat.
|
|
path = filepath.Join(home, rest)
|
|
}
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve openclaw config path %q: %w", path, err)
|
|
}
|
|
path = abs
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func openclawStatConfigPath(path string) (string, bool, error) {
|
|
if !filepath.IsAbs(path) {
|
|
return "", false, fmt.Errorf("openclaw reported non-absolute config path %q", path)
|
|
}
|
|
exists, err := openclawConfigPathExists(path)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return path, exists, nil
|
|
}
|
|
|
|
func openclawConfigPathExists(path string) (bool, error) {
|
|
info, err := os.Stat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, fmt.Errorf("stat openclaw config %s: %w", path, err)
|
|
}
|
|
if info.IsDir() {
|
|
return false, fmt.Errorf("openclaw config path %s is a directory, not a file", path)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func isOpenclawConfigFileUnsupported(err error) bool {
|
|
msg := strings.ToLower(err.Error())
|
|
return strings.Contains(msg, "too many arguments for 'config'") ||
|
|
strings.Contains(msg, "expected 0 arguments but got 1") ||
|
|
(strings.Contains(msg, "unknown") && strings.Contains(msg, "config") && strings.Contains(msg, "file"))
|
|
}
|
|
|
|
// openclawResolvedAgentsList fetches the user's resolved per-agent rows and
|
|
// reports which surface produced them (see openclawAgentsSource). The source
|
|
// matters downstream: rows that came from a config schema may be written back
|
|
// into the wrapper to pin per-agent workspaces, whereas registry rows MUST NOT
|
|
// be written back at all — see openclawRegistryAgentsList.
|
|
//
|
|
// Three schemas are supported:
|
|
//
|
|
// - Pre-2026.6: agents live in the config under `agents.list`. We read them
|
|
// via `openclaw config get agents.list --json`, which returns the post-
|
|
// include, post-env-substitution array. openclawAgentsSourceList.
|
|
// - 2026.8+: the same CLI reports `Unknown config path: agents.list` and
|
|
// carries the agents under `agents.entries.<id>` instead. That path *is*
|
|
// writable, so it is read through the same deadline as the attempt above
|
|
// and returned with only the id carried out of band; see
|
|
// openclawResolvedAgentsEntriesOrRegistry. openclawAgentsSourceEntries —
|
|
// a keyed entries payload is not `agents.list[]` config.
|
|
// - 2026.6.x/2026.7.x: neither config path exists — agents live in a sqlite
|
|
// registry. `config get agents.list` exits non-zero with "Config path not
|
|
// found: agents.list". We fall back to the `openclaw agents list --json`
|
|
// *subcommand*, whose rows must never be written back.
|
|
// openclawAgentsSourceRegistry.
|
|
//
|
|
// Returns a nil list when a source answered with no agents; the source still
|
|
// names what answered.
|
|
func openclawResolvedAgentsList(bin string, timeout time.Duration) ([]any, openclawAgentsSource, error) {
|
|
// One deadline for both config-schema probes. They ask the same question —
|
|
// what does the config schema carry per agent? — and the second is only
|
|
// reached when the first answers "no such path", exactly like
|
|
// `config validate --json` and its `config file` fallback in
|
|
// openclawActiveConfigPath. Giving the entries probe its own budget would
|
|
// take the worst case from three CLI deadlines to four (see
|
|
// openclawMaxCLIDeadlinesPerPreparation) without widening what the CLI can
|
|
// answer in total.
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
return openclawResolvedAgentsListWithinDeadline(ctx, bin, timeout)
|
|
}
|
|
|
|
// openclawResolvedAgentsListWithinDeadline is the shared-deadline half of
|
|
// openclawResolvedAgentsList: every config-schema probe it makes runs under the
|
|
// ctx it is handed, and only the registry fallback starts a budget of its own.
|
|
func openclawResolvedAgentsListWithinDeadline(ctx context.Context, bin string, timeout time.Duration) ([]any, openclawAgentsSource, error) {
|
|
out, err := openclawExec(ctx, bin, "config", "get", "agents.list", "--json")
|
|
if err != nil {
|
|
if isOpenclawKeyMissingResult(out, err, "agents.list") {
|
|
// The config path is gone. On 2026.8+ the keyed replacement exists;
|
|
// below that the agents live in the sqlite registry.
|
|
if openclawUnknownConfigPath(out, err, "agents.list") {
|
|
return openclawResolvedAgentsEntriesOrRegistry(ctx, bin, timeout)
|
|
}
|
|
list, rerr := openclawRegistryAgentsList(bin, timeout)
|
|
return list, openclawAgentsSourceRegistry, rerr
|
|
}
|
|
return nil, "", annotateOpenclawJSONError(err, out)
|
|
}
|
|
trimmed := strings.TrimSpace(out)
|
|
if trimmed == "" || trimmed == "null" {
|
|
return nil, openclawAgentsSourceList, nil
|
|
}
|
|
// An envelope that arrived without a non-zero exit must reach the same
|
|
// verdict as one that did; see openclawStdoutEnvelopeError. Missing the
|
|
// key here is what selects the registry, so letting the envelope through
|
|
// as data would turn a graceful fallback into a failed preparation.
|
|
if message, isEnvelope := openclawJSONErrorMessage(trimmed); isEnvelope {
|
|
// Matched on the path, not on the mere mention of "agents.list": the
|
|
// path-aware matcher knows every wording the CLI has used for "this key
|
|
// is not there" ("Path not found", "Config path not found",
|
|
// "Config path is valid but unset", "Unknown config path"), and it is
|
|
// what keeps an envelope about some *other* key fail-closed instead of
|
|
// silently downgrading this host to the registry.
|
|
if openclawKeyMissingMessageForPath(message, "agents.list") {
|
|
if openclawUnknownConfigPathMessage(message, "agents.list") {
|
|
return openclawResolvedAgentsEntriesOrRegistry(ctx, bin, timeout)
|
|
}
|
|
list, rerr := openclawRegistryAgentsList(bin, timeout)
|
|
return list, openclawAgentsSourceRegistry, rerr
|
|
}
|
|
return nil, "", openclawStdoutEnvelopeError("config get agents.list --json", message)
|
|
}
|
|
var list []any
|
|
if err := json.Unmarshal([]byte(trimmed), &list); err != nil {
|
|
return nil, "", fmt.Errorf("parse `openclaw config get agents.list --json` output: %w", err)
|
|
}
|
|
return list, openclawAgentsSourceList, nil
|
|
}
|
|
|
|
// openclawResolvedAgentsEntriesOrRegistry runs the config-schema read that
|
|
// 2026.8+ replaced `agents.list` with, then falls back to the registry for the
|
|
// generations that have neither.
|
|
//
|
|
// `agents.entries` is a map keyed by agent id, and that key is the only place
|
|
// the id exists — the entry object itself may carry no `id` field at all (a
|
|
// minimal `{"workspace": "..."}` is a valid entry). Each resolved row therefore
|
|
// carries the key out of band, and nothing else: the payload is redacted and is
|
|
// never written back (see rewriteAgentsEntriesWorkspaces). That marker is also
|
|
// how the write-back tells these rows apart from registry rows, which must
|
|
// never reach the wrapper.
|
|
//
|
|
// Reports openclawAgentsSourceEntries when this path answered, and
|
|
// openclawAgentsSourceRegistry when it fell back to the sqlite registry — the
|
|
// generations where that fallback fires are exactly the ones where
|
|
// `agents.entries` is not a valid config path, so the caller must not write the
|
|
// rows back under that key.
|
|
func openclawResolvedAgentsEntriesOrRegistry(ctx context.Context, bin string, timeout time.Duration) ([]any, openclawAgentsSource, error) {
|
|
out, err := openclawExec(ctx, bin, "config", "get", "agents.entries", "--json")
|
|
if err != nil {
|
|
if isOpenclawKeyMissingResult(out, err, "agents.entries") {
|
|
list, rerr := openclawRegistryAgentsList(bin, timeout)
|
|
return list, openclawAgentsSourceRegistry, rerr
|
|
}
|
|
return nil, "", annotateOpenclawJSONError(err, out)
|
|
}
|
|
trimmed := strings.TrimSpace(out)
|
|
if trimmed == "" || trimmed == "null" {
|
|
return nil, openclawAgentsSourceEntries, nil
|
|
}
|
|
// Same envelope rule as the `agents.list` probe above: on the generations
|
|
// where this path is valid but the user has not written it, the CLI reports
|
|
// "Config path is valid but unset: agents.entries" — which means the host has
|
|
// no in-config agents, and the registry is still the only place they can be.
|
|
if message, isEnvelope := openclawJSONErrorMessage(trimmed); isEnvelope {
|
|
if openclawKeyMissingMessageForPath(message, "agents.entries") {
|
|
list, rerr := openclawRegistryAgentsList(bin, timeout)
|
|
return list, openclawAgentsSourceRegistry, rerr
|
|
}
|
|
return nil, "", openclawStdoutEnvelopeError("config get agents.entries --json", message)
|
|
}
|
|
var entries map[string]any
|
|
if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
|
|
return nil, "", fmt.Errorf("parse `openclaw config get agents.entries --json` output: %w", err)
|
|
}
|
|
list := make([]any, 0, len(entries))
|
|
for id, raw := range entries {
|
|
if _, ok := raw.(map[string]any); !ok {
|
|
return nil, "", fmt.Errorf("parse `openclaw config get agents.entries --json` output: entry %q is not an object", id)
|
|
}
|
|
list = append(list, map[string]any{openclawEntriesKeyField: id})
|
|
}
|
|
return list, openclawAgentsSourceEntries, nil
|
|
}
|
|
|
|
// openclawRegistryAgentsList resolves agents from the sqlite-backed registry
|
|
// via `openclaw agents list --json` (OpenClaw 2026.6.x+).
|
|
//
|
|
// **The result is for read-side use only — it must never be written back into
|
|
// the wrapper as `agents.list`.** The registry entries carry CLI-only fields
|
|
// (identityName, identitySource, agentDir, bindings, isDefault) that are NOT
|
|
// part of the 2026.6.x config schema's `agents.list[]` shape; OpenClaw's
|
|
// validator rejects them ("agents.list.0: Invalid input") and fails closed
|
|
// before the agent runs. Worse, `agents.list` is no longer a valid config path
|
|
// at all in 2026.6.x. On these generations there is no in-config way to pin a
|
|
// per-agent workspace, so the per-task workspace is pinned via
|
|
// `agents.defaults.workspace` alone, which the wrapper always sets and which
|
|
// OpenClaw applies to the agent it selects from the registry (verified on
|
|
// 2026.6.8). 2026.8+ hosts do have a writable per-agent path again —
|
|
// `agents.entries.<id>` — and reach it through openclawResolvedAgentsEntriesOrRegistry
|
|
// without ever consulting this function.
|
|
//
|
|
// Callers gate the write-back on the source
|
|
// openclawResolvedAgentsList reports (openclawAgentsSourceRegistry here, which
|
|
// no write-back branch accepts); rows from this subcommand are therefore never
|
|
// written to the wrapper by any route.
|
|
//
|
|
// Returns nil (not an error) when the registry is empty or the subcommand
|
|
// reports no agents.
|
|
func openclawRegistryAgentsList(bin string, timeout time.Duration) ([]any, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
out, err := openclawExec(ctx, bin, "agents", "list", "--json")
|
|
if err != nil {
|
|
// Older OpenClaw builds may lack the subcommand entirely; treat an
|
|
// unrecognized/missing subcommand the same as "no agents to pin"
|
|
// rather than failing closed, since the defaults.workspace override
|
|
// alone still gives correct per-task skill discovery for the common
|
|
// single-agent case.
|
|
if isOpenclawKeyMissing(err) || isOpenclawUnknownSubcommand(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, annotateOpenclawJSONError(err, out)
|
|
}
|
|
trimmed := strings.TrimSpace(out)
|
|
if trimmed == "" || trimmed == "null" {
|
|
return nil, nil
|
|
}
|
|
// Same reasoning as the resolver above: without a non-zero exit the envelope
|
|
// only shows up here.
|
|
if message, isEnvelope := openclawJSONErrorMessage(trimmed); isEnvelope {
|
|
if isOpenclawKeyMissingMessage(message) || isOpenclawUnknownSubcommandMessage(message) {
|
|
return nil, nil
|
|
}
|
|
return nil, openclawStdoutEnvelopeError("agents list --json", message)
|
|
}
|
|
var list []any
|
|
if err := json.Unmarshal([]byte(trimmed), &list); err != nil {
|
|
return nil, fmt.Errorf("parse `openclaw agents list --json` output: %w", err)
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
// openclawExec is the runtime hook prepareOpenclawConfig uses to invoke the
|
|
// openclaw CLI. Production points at execOpenclawCLI; tests swap in a stub
|
|
// to avoid spawning a real binary. Production code never reassigns it.
|
|
var openclawExec = execOpenclawCLI
|
|
|
|
// openclawLastNonEmptyLine returns the last non-empty, trimmed line of out.
|
|
// Used by openclawParseActiveConfigPath for the `config file` fallback, where the
|
|
// path is the last line the CLI prints.
|
|
func openclawLastNonEmptyLine(out string) string {
|
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if trimmed := strings.TrimSpace(lines[i]); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// openclawOutputComplete returns the rule that decides whether the bytes
|
|
// captured so far are a finished answer for this openclaw subcommand, for
|
|
// agent.RunCollectQuiet's early return.
|
|
//
|
|
// Only `--json` commands get one, and that is the whole design rather than a gap
|
|
// to fill in later. Two properties make a JSON answer recognisable, and neither
|
|
// has an equivalent for human-readable output:
|
|
//
|
|
// - The document has to parse *as a whole*, so a response still being written
|
|
// cannot satisfy the rule, no matter how long the writer pauses mid-way.
|
|
// - A `--json` stdout carries the document and nothing else. Measured on
|
|
// 2026.7.1-2 with a warning-producing config, `config validate --json` emitted
|
|
// one 715-byte line that parses whole, with the Doctor and plugin warnings
|
|
// carried as a `warnings` array inside it. Without `--json` those same
|
|
// warnings are 28 lines of UI sharing stdout with the answer.
|
|
//
|
|
// An earlier revision of this branch also had a rule for `config file`: accept
|
|
// the buffer once its last non-empty line looks like a path. Review broke it with
|
|
// a stub that prints an existing `plugin-cache.json` path, pauses past any fixed
|
|
// grace, and then prints the real path — the warning was returned as the answer.
|
|
// That is not fixable by waiting longer, because the pause is the host's plugin
|
|
// and Doctor work and has no bound; it is fixable by not asking a question that
|
|
// content cannot answer. openclawActiveConfigPath now prefers
|
|
// `config validate --json`, whose path arrives in a named field.
|
|
//
|
|
// A nil result means "no rule for this shape", which makes RunCollectQuiet wait
|
|
// for the process to exit — the conservative behaviour, and what `config file`
|
|
// now gets when it is used as the fallback.
|
|
func openclawOutputComplete(args []string) agent.OutputComplete {
|
|
for _, a := range args {
|
|
if a == "--json" {
|
|
return agent.JSONOutputComplete
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// execOpenclawCLI executes an openclaw subcommand and returns its stdout,
|
|
// including stdout captured before a non-zero exit. Failed stdout stays in the
|
|
// separate return value and this execution layer never appends it to the error:
|
|
// config commands can print resolved configuration and secrets there. JSON
|
|
// callers may extract only a bounded error-envelope field through
|
|
// annotateOpenclawJSONError; arbitrary failed stdout remains non-diagnostic.
|
|
// The daemon's environment is inherited so OPENCLAW_CONFIG_PATH /
|
|
// OPENCLAW_STATE_DIR / OPENCLAW_HOME / OPENCLAW_INCLUDE_ROOTS pass through.
|
|
//
|
|
// stderr is captured separately and appended to error messages — failures
|
|
// here surface up to the daemon log, and a `openclaw doctor` hint there is
|
|
// more useful than just an exit code.
|
|
//
|
|
// When the CLI is a batch shim that exits non-zero and says nothing at all,
|
|
// openclawShimDiagnostic adds the interpreter-resolution detail that a bare
|
|
// `exit status 1` hides (MUL-5422 / #6061). Real stderr always wins — the
|
|
// diagnostic is a fallback for the silent case, not a replacement.
|
|
//
|
|
// Attribution order matters. openclawCLITimeout kills the child via
|
|
// CommandContext, and a killed process surfaces as *exec.ExitError
|
|
// ("signal: killed") — indistinguishable by type from a genuine exit 1. So the
|
|
// context is checked FIRST; otherwise a timeout gets reported as "node is not
|
|
// on PATH, install Node.js", sending the user to fix something that was never
|
|
// broken.
|
|
//
|
|
// In that branch the CONTEXT error is what gets %w-wrapped, not the process
|
|
// error, so errors.Is(err, context.DeadlineExceeded) holds for callers that
|
|
// check cancellation the standard way. The process error is still printed for
|
|
// diagnosis, just not as the wrapped cause.
|
|
func execOpenclawCLI(ctx context.Context, bin string, args ...string) (string, error) {
|
|
// agent.RunCollectQuiet, not cmd.Output(): this package owns the pipes and
|
|
// the process tree, which is what makes the deadline above enforceable at all
|
|
// (MUL-5467 — see openclawCLITimeout). The per-subcommand rule from
|
|
// openclawOutputComplete additionally lets a CLI that prints its answer and
|
|
// then refuses to exit be treated as finished, so `openclaw config file` no
|
|
// longer has to reach the deadline to be useful.
|
|
//
|
|
// Every error shape below is unchanged, including returning captured stdout
|
|
// alongside the error: annotateOpenclawJSONError reads it, and the typed
|
|
// timeout sentinel is what lets the daemon classify a local stall
|
|
// structurally.
|
|
raw, stderrOut, _, err := agent.RunCollectQuiet(ctx, os.Environ(), 0, openclawOutputComplete(args), bin, args...)
|
|
stdout := string(raw)
|
|
if err != nil {
|
|
stderrMsg := strings.TrimSpace(stderrOut)
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
msg := fmt.Sprintf("openclaw %s: %v (process: %v)", strings.Join(args, " "), ctxErr, err)
|
|
if stderrMsg != "" {
|
|
msg = fmt.Sprintf("openclaw %s: %v (process: %v; stderr: %s)", strings.Join(args, " "), ctxErr, err, stderrMsg)
|
|
}
|
|
// A deadline is reported through openclawCLITimeoutError so the
|
|
// daemon can recognise a local CLI stall structurally
|
|
// (ErrOpenclawCLITimeout) instead of string-matching "deadline
|
|
// exceeded" — which routed it into agent_error.provider_network,
|
|
// i.e. "check your network" copy plus an auto-retry, for a failure
|
|
// that is local and deterministic.
|
|
//
|
|
// Cancellation deliberately does NOT get the sentinel: a daemon
|
|
// shutdown or a cancelled task is not a slow CLI, and labelling it
|
|
// as one would tell the user to raise a timeout that was never the
|
|
// problem. Both keep the wrapped context error, so
|
|
// errors.Is(err, context.DeadlineExceeded) / context.Canceled work
|
|
// exactly as before.
|
|
if errors.Is(ctxErr, context.DeadlineExceeded) {
|
|
return stdout, &openclawCLITimeoutError{msg: msg, cause: ctxErr}
|
|
}
|
|
if stderrMsg != "" {
|
|
return stdout, fmt.Errorf("openclaw %s: %w (process: %v; stderr: %s)", strings.Join(args, " "), ctxErr, err, stderrMsg)
|
|
}
|
|
return stdout, fmt.Errorf("openclaw %s: %w (process: %v)", strings.Join(args, " "), ctxErr, err)
|
|
}
|
|
if stderrMsg != "" {
|
|
return stdout, fmt.Errorf("openclaw %s: %w (stderr: %s)", strings.Join(args, " "), err, stderrMsg)
|
|
}
|
|
if diag := openclawShimDiagnostic(bin, err); diag != "" {
|
|
return stdout, fmt.Errorf("openclaw %s: %w (%s)", strings.Join(args, " "), err, diag)
|
|
}
|
|
return stdout, fmt.Errorf("openclaw %s: %w", strings.Join(args, " "), err)
|
|
}
|
|
return stdout, nil
|
|
}
|
|
|
|
// openclawManagedMcpServers parses the agent's `mcp_config` JSON and returns
|
|
// the map of server name → server config that the wrapper should emit at
|
|
// `mcp.servers`. The second return is `true` when the agent has a managed
|
|
// mcp_config saved (non-null) — including the explicit empty set
|
|
// `{}` / `{"mcpServers":{}}` — and `false` when the field is null/absent so
|
|
// the user's global config flows through unmodified.
|
|
//
|
|
// Input shape mirrors the rest of Multica: Claude-style
|
|
// `{"mcpServers": {"<name>": {...}}}`. The server-entry fields pass through
|
|
// verbatim. OpenClaw's stdio schema uses the same camelCase keys (`command`,
|
|
// `args`, `env`) as Claude; HTTP/SSE entries should set OpenClaw's
|
|
// `transport` field directly (e.g. `"transport": "streamable-http"`) rather
|
|
// than Claude's `type` since OpenClaw does not recognise the latter.
|
|
//
|
|
// Each entry must declare either `command` (stdio) or `url` (http/sse); any
|
|
// other shape returns an error so the launch fails closed with an actionable
|
|
// message rather than running with a server OpenClaw will refuse to start.
|
|
func openclawManagedMcpServers(raw json.RawMessage) (map[string]any, bool, error) {
|
|
trimmed := bytes.TrimSpace(raw)
|
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
|
return nil, false, nil
|
|
}
|
|
var parsed struct {
|
|
McpServers map[string]json.RawMessage `json:"mcpServers"`
|
|
}
|
|
if err := json.Unmarshal(trimmed, &parsed); err != nil {
|
|
return nil, false, fmt.Errorf("parse mcp_config json: %w", err)
|
|
}
|
|
if len(parsed.McpServers) == 0 {
|
|
return map[string]any{}, true, nil
|
|
}
|
|
names := make([]string, 0, len(parsed.McpServers))
|
|
for name := range parsed.McpServers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
out := make(map[string]any, len(names))
|
|
for _, name := range names {
|
|
var entry map[string]any
|
|
if err := json.Unmarshal(parsed.McpServers[name], &entry); err != nil {
|
|
return nil, false, fmt.Errorf("mcp_servers.%s: %w", name, err)
|
|
}
|
|
if entry == nil {
|
|
return nil, false, fmt.Errorf("mcp_servers.%s must be a JSON object", name)
|
|
}
|
|
command, _ := entry["command"].(string)
|
|
url, _ := entry["url"].(string)
|
|
if strings.TrimSpace(command) == "" && strings.TrimSpace(url) == "" {
|
|
return nil, false, fmt.Errorf("mcp_servers.%s must declare either `command` (stdio) or `url` (http/sse)", name)
|
|
}
|
|
out[name] = entry
|
|
}
|
|
return out, true, nil
|
|
}
|
|
|
|
// isOpenclawKeyMissing returns true when the CLI error indicates the asked-
|
|
// for path simply isn't set, as opposed to a real failure (bad config,
|
|
// CLI bug, missing binary). The CLI's "key not found" exit text has varied
|
|
// across versions, so we match on a handful of substrings rather than the
|
|
// exit code alone.
|
|
func isOpenclawKeyMissing(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return isOpenclawKeyMissingMessage(err.Error())
|
|
}
|
|
|
|
const openclawJSONErrorMaxRunes = 1024
|
|
|
|
func openclawJSONErrorMessage(stdout string) (string, bool) {
|
|
var envelope struct {
|
|
Error json.RawMessage `json:"error"`
|
|
}
|
|
if json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope) != nil || len(envelope.Error) == 0 {
|
|
return "", false
|
|
}
|
|
var message string
|
|
if err := json.Unmarshal(envelope.Error, &message); err != nil {
|
|
// OpenClaw 2026.8.1-beta.3 changed JSON failures from
|
|
// {"error":"message"} to
|
|
// {"ok":false,"error":{"type":"cli_error","message":"message"}}.
|
|
// Extract only the diagnostic field in either shape; sibling fields can
|
|
// still carry resolved configuration or secrets.
|
|
var detail struct {
|
|
Message string `json:"message"`
|
|
}
|
|
if json.Unmarshal(envelope.Error, &detail) != nil {
|
|
return "", false
|
|
}
|
|
message = detail.Message
|
|
}
|
|
message = strings.Join(strings.Fields(message), " ")
|
|
return message, message != ""
|
|
}
|
|
|
|
// annotateOpenclawJSONError restores diagnostics for JSON-mode commands whose
|
|
// CLI errors are written to stdout. Only the envelope's diagnostic message is
|
|
// included, whether `error` is a string or an object: sibling fields may
|
|
// contain resolved configuration or secrets. The message is
|
|
// whitespace-normalized for single-line logs and rune-bounded to keep persisted
|
|
// task errors finite while preserving valid UTF-8.
|
|
func annotateOpenclawJSONError(err error, stdout string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
message, ok := openclawJSONErrorMessage(stdout)
|
|
if !ok {
|
|
return err
|
|
}
|
|
return fmt.Errorf("%w (json error: %s)", err, openclawBoundedJSONErrorMessage(message))
|
|
}
|
|
|
|
func openclawBoundedJSONErrorMessage(message string) string {
|
|
runes := []rune(message)
|
|
if len(runes) > openclawJSONErrorMaxRunes {
|
|
return string(runes[:openclawJSONErrorMaxRunes]) + "…"
|
|
}
|
|
return message
|
|
}
|
|
|
|
// openclawStdoutEnvelopeError turns a CLI error envelope that arrived on stdout
|
|
// *without* a non-zero exit into an error.
|
|
//
|
|
// The completeness rules let RunCollectQuiet accept a finished-looking answer
|
|
// from a CLI that has printed it but not exited yet (see openclawOutputComplete),
|
|
// and a JSON error envelope is itself valid JSON, so JSONOutputComplete accepts
|
|
// one. The exit-status path therefore no longer sees every CLI error: a build
|
|
// that prints `{"error": "..."}` and then lingers past the idle grace hands back
|
|
// err == nil with the envelope sitting in stdout. Callers must check for that
|
|
// explicitly, because decoding an envelope as data is silent for an object
|
|
// target — it would be written straight into the generated config — and only
|
|
// accidentally noisy for a list target, where the type mismatch surfaces as an
|
|
// opaque parse error instead of the CLI's own message.
|
|
func openclawStdoutEnvelopeError(command, message string) error {
|
|
return fmt.Errorf("`openclaw %s` reported: %s", command, openclawBoundedJSONErrorMessage(message))
|
|
}
|
|
|
|
// isOpenclawKeyMissingResult recognizes the "you asked for a key that is not
|
|
// there" failures of `config get ... --json`. Which channel reports it where was
|
|
// measured on 2026-08-26, and the spread is why both transports stay supported:
|
|
//
|
|
// 2026.6.34 (extended-stable) stderr text, stdout empty
|
|
// 2026.7.1-2 (latest) stderr text, stdout empty
|
|
// 2026.7.2-beta.7 stdout {"error":"..."}
|
|
// 2026.8.1-beta.3 (beta) stdout {"ok":false,"error":{"message":...}}
|
|
//
|
|
// So the stdout envelope is not "the new way" that replaced stderr — the current
|
|
// stable and latest channels are stderr-only, and only the beta line puts it on
|
|
// stdout. Dropping either transport breaks a channel that is shipping today.
|
|
//
|
|
// Wording also moved: 2026.6.34 and 2026.7.1-2 say "Config path not found: mcp",
|
|
// 2026.8.1-beta.3 says "Config path is valid but unset: mcp" for a key the schema
|
|
// knows and "Unknown config path: agents.list" for one it does not.
|
|
//
|
|
// Structured messages must name the path we asked for. That is what keeps a
|
|
// *usage* error out of this branch: the pathless `config get --json` form fails
|
|
// with `Missing required argument "path".`, which names no path and so cannot be
|
|
// read as an absent key. Cancellation and timeout keep their original meaning
|
|
// even if a child emitted a partial missing-path envelope before it stopped.
|
|
func isOpenclawKeyMissingResult(stdout string, err error, keyPath string) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
return false
|
|
}
|
|
if openclawKeyMissingMessageForPath(err.Error(), keyPath) {
|
|
return true
|
|
}
|
|
message, ok := openclawJSONErrorMessage(stdout)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return openclawKeyMissingMessageForPath(message, keyPath)
|
|
}
|
|
|
|
// openclawUnknownConfigPath recognizes the one missing-path wording that also
|
|
// tells us *which* generation of the schema we are talking to: 2026.8+ reports
|
|
// `Unknown config path: agents.list` for the path it retired and carries the
|
|
// agents under `agents.entries` instead. The older generations say "Config path
|
|
// not found" / "Config path is valid but unset" for paths they know nothing
|
|
// about and have no keyed replacement, so those keep the registry fallback —
|
|
// probing `agents.entries` there would only spend a probe to be told about a
|
|
// path that generation never had.
|
|
func openclawUnknownConfigPath(stdout string, err error, keyPath string) bool {
|
|
if message, ok := openclawJSONErrorMessage(stdout); ok {
|
|
return openclawUnknownConfigPathMessage(message, keyPath)
|
|
}
|
|
return err != nil && openclawUnknownConfigPathMessage(err.Error(), keyPath)
|
|
}
|
|
|
|
func openclawUnknownConfigPathMessage(message, keyPath string) bool {
|
|
message = strings.ToLower(strings.Join(strings.Fields(message), " "))
|
|
keyPath = strings.ToLower(strings.TrimSpace(keyPath))
|
|
return keyPath != "" && openclawMessageNamesPathAfter(message, "unknown config path: ", keyPath)
|
|
}
|
|
|
|
func openclawKeyMissingMessageForPath(message, keyPath string) bool {
|
|
message = strings.ToLower(strings.Join(strings.Fields(message), " "))
|
|
keyPath = strings.ToLower(strings.TrimSpace(keyPath))
|
|
if keyPath == "" {
|
|
return false
|
|
}
|
|
for _, marker := range []string{
|
|
"no value at ",
|
|
"missing key: ",
|
|
"missing key ",
|
|
"path not found: ",
|
|
"config path is valid but unset: ",
|
|
"unknown config path: ",
|
|
} {
|
|
if openclawMessageNamesPathAfter(message, marker, keyPath) {
|
|
return true
|
|
}
|
|
}
|
|
if openclawMessageNamesPathBefore(message, keyPath, " is not set") ||
|
|
openclawMessageNamesPathBefore(message, keyPath, " not set") ||
|
|
strings.Contains(message, "config path "+keyPath+" not found") {
|
|
return true
|
|
}
|
|
// Some pre-2026.6 builds returned only "Path not found" after the
|
|
// command wrapper. Accept that exact bare diagnostic when the command names
|
|
// the expected key, but do not combine the command path with an unrelated
|
|
// "not set" message later in stderr.
|
|
return strings.Contains(message, "config get "+keyPath) &&
|
|
(strings.HasSuffix(message, ": path not found") ||
|
|
strings.Contains(message, ": path not found ("))
|
|
}
|
|
|
|
func openclawMessageNamesPathBefore(message, keyPath, suffix string) bool {
|
|
needle := keyPath + suffix
|
|
for offset := 0; offset < len(message); {
|
|
relative := strings.Index(message[offset:], needle)
|
|
if relative < 0 {
|
|
return false
|
|
}
|
|
idx := offset + relative
|
|
leftBoundary := idx == 0 || isOpenclawPathLeftBoundary(message[idx-1])
|
|
if leftBoundary && isOpenclawPathBoundary(message[idx+len(needle):]) {
|
|
return true
|
|
}
|
|
offset = idx + len(keyPath)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func openclawMessageNamesPathAfter(message, marker, keyPath string) bool {
|
|
for search := message; ; {
|
|
idx := strings.Index(search, marker)
|
|
if idx < 0 {
|
|
return false
|
|
}
|
|
rest := search[idx+len(marker):]
|
|
if strings.HasPrefix(rest, keyPath) && isOpenclawPathBoundary(rest[len(keyPath):]) {
|
|
return true
|
|
}
|
|
search = rest
|
|
}
|
|
}
|
|
|
|
func isOpenclawPathLeftBoundary(char byte) bool {
|
|
switch char {
|
|
case ' ', '(', '[', '{', ',', ';', ':', '\'', '"', '`':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isOpenclawPathBoundary(rest string) bool {
|
|
if rest == "" {
|
|
return true
|
|
}
|
|
switch rest[0] {
|
|
case ' ', ')', ']', '}', ',', ';', ':', '\'', '"', '`':
|
|
return true
|
|
case '.':
|
|
return len(rest) == 1 || rest[1] == ' ' || rest[1] == ')' || rest[1] == ']'
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isOpenclawKeyMissingMessage(msg string) bool {
|
|
// Match case-insensitively: the CLI's "key not found" wording has drifted
|
|
// across versions and capitalization is not stable. Pre-2026.6 emitted
|
|
// "Path not found"; OpenClaw 2026.6.x emits "Config path not found:
|
|
// agents.list" (lowercase "path", "Config" prefix). A case-sensitive
|
|
// strings.Contains on "Path not found" silently stopped matching the
|
|
// 2026.6.x string, turning the intended graceful-skip into a fail-closed
|
|
// error that broke every OpenClaw 2026.6.x runtime (see upstream #3028).
|
|
msg = strings.ToLower(msg)
|
|
return strings.Contains(msg, "no value at ") ||
|
|
strings.Contains(msg, "not set") ||
|
|
strings.Contains(msg, "missing key") ||
|
|
strings.Contains(msg, "path not found")
|
|
}
|
|
|
|
// isOpenclawUnknownSubcommand returns true when the CLI error indicates the
|
|
// invoked subcommand/option does not exist on this OpenClaw build (e.g. an
|
|
// older release predating `openclaw agents list --json`). Used so the
|
|
// registry fallback degrades to "no agents to pin" rather than failing
|
|
// closed on builds that never had the subcommand.
|
|
func isOpenclawUnknownSubcommand(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
return isOpenclawUnknownSubcommandMessage(err.Error())
|
|
}
|
|
|
|
func isOpenclawUnknownSubcommandMessage(msg string) bool {
|
|
msg = strings.ToLower(msg)
|
|
return strings.Contains(msg, "unknown command") ||
|
|
strings.Contains(msg, "unknown option") ||
|
|
strings.Contains(msg, "does not recognize") ||
|
|
strings.Contains(msg, "unknown argument")
|
|
}
|