mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 14:00:09 +02:00
* MUL-2778 feat(agent): wire mcp_config through OpenClaw runtime The MCP config tab (#3419) lets admins save mcp_config on an agent, and recent work (#3439) plumbed it through the three ACP runtimes. OpenClaw still ignored the field, leaving the Tab silently inert for any OpenClaw-backed agent. Translate the agent's Claude-style `{"mcpServers": {...}}` into the per-task OpenClaw wrapper's `mcp.servers` block — OpenClaw resolves MCP via its own config schema rather than ExecOptions, so the existing OPENCLAW_CONFIG_PATH preparer is the right seam. Fail closed on malformed JSON / entries missing `command` or `url`, matching the fail-closed posture the preparer already uses for the agents.list step. Null / absent mcp_config leaves the wrapper free of an `mcp` key so the user's global mcp.servers flows through untouched; an explicit empty managed set (`{}` / `{"mcpServers":{}}`) is honoured as "admin saved no servers" mirroring `hasManagedCodexMcpConfig`. Strict-mode replacement (drop user-only servers entirely) would require OpenClaw to do a per-key replace rather than a deep merge at `mcp.servers`; the comment documents that caveat rather than relying on undocumented behaviour. Also adds `openclaw` to `MCP_SUPPORTED_PROVIDERS` so the MCP Tab actually surfaces in the agent overview pane, and pins the new visibility case with a renderPane test. Co-authored-by: multica-agent <github@multica.ai> * MUL-2778 fix(agent): make openclaw mcp_config strict-replace via sanitized snapshot Elon flagged on #3450 that the previous wiring let user-only mcp.servers leak through the wrapper's `$include` of the live user config: deep-merge at `mcp.servers` keeps user-only names, and the strict-empty case (`{ "mcpServers": {} }`) silently inherited user globals. Switch the strict-replace path to write a sanitized snapshot of the user's fully resolved config (via `openclaw config get --json`) with the `mcp` block stripped, then have the wrapper `$include` the snapshot instead of the live user file. With the user's `mcp` gone from the $include resolution, the wrapper's `mcp.servers` is the only definition the embedded OpenClaw sees — managed only, including the explicit empty set. The snapshot lives in envRoot at 0o600 alongside the wrapper so the GC reaper sweeps it with the rest of the task scratch, and no extra OPENCLAW_INCLUDE_ROOTS entry is needed (same-dir $include). Fail-closed on `config get --json` errors so the daemon never silently falls back to the leaky $include path. The inherit branch (null mcp_config) still uses the live user file directly — no extra CLI roundtrip and no snapshot is written. New tests pin the contract Elon's review required: - TestPrepareOpenclawConfigStrictReplacesUserMcpServers: user has global_one + shared, managed has shared + managed_only → wrapper has exactly {shared (managed value), managed_only}; global_one does NOT leak; snapshot file has the user's `mcp` stripped while preserving gateway / providers / API keys. - TestPrepareOpenclawConfigStrictEmptyManagedSetDropsUserMcp: empty managed set drops user's global_one (both `{}` and `{"mcpServers":{}}` cases). - TestPrepareOpenclawConfigNullMcpConfigKeepsUserInclude: null path inherits the live user config, writes no snapshot, makes no extra CLI call. - TestPrepareOpenclawConfigFailsClosedOnResolvedConfigError: errors during `config get --json` surface; no stale wrapper or snapshot. - TestPrepareOpenclawConfigManagedSetFreshInstall: fresh install with managed mcp_config skips the snapshot dance entirely. Also tightens en + zh-Hans MCP Tab copy to mention OpenClaw goes via the per-task wrapper, and to use OpenClaw's own `transport` field rather than Claude's `type` for HTTP/SSE entries. Co-authored-by: multica-agent <github@multica.ai> * MUL-2778 fix(agent): narrow openclaw snapshot strip to mcp.servers only Elon's third-round must-fix: the previous strict-replace snapshot deleted the entire `mcp` block, which wiped out non-server settings under `mcp` like `sessionIdleTtlMs`. Those are documented OpenClaw config keys (https://docs.openclaw.ai/gateway/configuration-reference#mcp) outside the MCP Tab's scope — the agent's saved mcp_config only manages server definitions, so other `mcp.*` tuning the user set must survive. Replace the blanket `delete(resolved, "mcp")` with a stripUserMcpServers helper that: - deletes only `mcp.servers` when `mcp` is an object - drops the parent `mcp` key only when the object is empty after the strip (so we don't emit `mcp: {}` placeholders) - leaves non-object `mcp` values untouched (we only know how to strip servers from the documented shape) Pinned with TestPrepareOpenclawConfigStrictPreservesNonServerMcpKeys: user resolved has both `mcp.sessionIdleTtlMs: 300000` and `mcp.servers.global_one`; after the strict path runs the snapshot keeps the TTL and drops the servers map, and the wrapper's `mcp.servers` is exactly the managed set with no leak. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
539 lines
23 KiB
Go
539 lines
23 KiB
Go
package execenv
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// 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"
|
|
|
|
// openclawUserSnapshotFile is the sanitized copy of the user's fully
|
|
// resolved openclaw config the wrapper $includes when the agent has a
|
|
// managed mcp_config. It is the user's config minus the `mcp` block so the
|
|
// wrapper's managed `mcp.servers` is the only MCP definition visible to
|
|
// OpenClaw — true strict-replace, not deep-merge-by-name. Lives in envRoot
|
|
// at 0o600 next to the wrapper.
|
|
const openclawUserSnapshotFile = "openclaw-user-snapshot.json"
|
|
|
|
// openclawCLITimeout caps each `openclaw config ...` invocation during task
|
|
// setup. The CLI is fast (<200ms normal); 5s leaves headroom for a cold
|
|
// node start without letting a hung CLI stall task dispatch indefinitely.
|
|
const openclawCLITimeout = 5 * time.Second
|
|
|
|
// OpenclawConfigPrep is the input to prepareOpenclawConfig. Only OpenclawBin
|
|
// is meaningful in production — Timeout is here for tests that need a tight
|
|
// cap 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 caps each CLI invocation. Zero falls back to openclawCLITimeout.
|
|
Timeout time.Duration
|
|
// 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
|
|
}
|
|
|
|
// 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 OpenClaw config to envRoot and
|
|
// returns its absolute path along with the include root the daemon must
|
|
// grant. The daemon sets OPENCLAW_CONFIG_PATH to the path on the spawned
|
|
// openclaw subprocess so the CLI resolves its `agents.defaults.workspace`
|
|
// (and every `agents.list[].workspace`) to the task workdir — which is
|
|
// what makes OpenClaw's native skill scanner pick up the per-task skills
|
|
// we write under `<workDir>/skills/`.
|
|
//
|
|
// Strategy: delegate JSON5 / $include / env-substitution / state-dir
|
|
// resolution to the openclaw CLI itself rather than re-implementing the
|
|
// spec. We:
|
|
//
|
|
// 1. Run `openclaw config file` to find the user's active config path
|
|
// (handles OPENCLAW_CONFIG_PATH, OPENCLAW_STATE_DIR, OPENCLAW_HOME, and
|
|
// the default location).
|
|
// 2. Run `openclaw config get agents.list --json` to enumerate every
|
|
// registered agent ID with its resolved fields. The CLI parses JSON5,
|
|
// follows $include, and substitutes ${VAR} for us.
|
|
// 3. Write a wrapper config to envRoot/openclaw-config.json that
|
|
// `$include`s the active path and overrides
|
|
// `agents.defaults.workspace` plus every `agents.list[].workspace` to
|
|
// workDir. The original config bytes are not mutated — they are loaded
|
|
// by openclaw's own loader through the $include link, which preserves
|
|
// comments, secrets, and nested $include chains verbatim.
|
|
//
|
|
// **Cross-directory $include confinement.** OpenClaw confines `$include`
|
|
// resolution to the directory containing the wrapper file unless the
|
|
// target's parent is listed in `OPENCLAW_INCLUDE_ROOTS`. Our wrapper lives
|
|
// in envRoot but $includes the user's active config (typically
|
|
// `~/.openclaw/openclaw.json`) — a cross-directory hop. We surface
|
|
// `filepath.Dir(activePath)` as IncludeRoot so the daemon can prepend it
|
|
// to whatever the user already has in OPENCLAW_INCLUDE_ROOTS; without
|
|
// this, OpenClaw refuses to follow the link and the wrapper boots with no
|
|
// user config. Fresh install emits no $include, so IncludeRoot is "".
|
|
//
|
|
// **Intentional task isolation.** The override of every per-agent workspace
|
|
// is deliberate. OpenClaw's resolution order is
|
|
// `agents.list[id].workspace → agents.defaults.workspace → ~/.openclaw/
|
|
// workspace`. Pinning only the default would let a per-agent workspace the
|
|
// user configured at host scope silently re-route the scanner back to the
|
|
// shared workspace, defeating the per-task skill discovery this whole flow
|
|
// exists for. The cost is that any per-agent SOUL.md / MEMORY.md / standing
|
|
// orders the user laid in `<host-agent-workspace>/` are NOT visible to the
|
|
// in-task openclaw run — task isolation wins over host carry-over. The
|
|
// user's on-disk config is untouched; this only affects the wrapper used
|
|
// for this single task.
|
|
//
|
|
// **Fail closed.** Missing openclaw binary, CLI errors, malformed CLI
|
|
// output, or any IO error during write surfaces as an error to the caller
|
|
// rather than degrading to a minimal config. An earlier version silently
|
|
// synthesized a minimal config on parse failure; that masked broken user
|
|
// configs by starting OpenClaw without the registered agents / model
|
|
// providers / API keys it expects, which led to tasks routing to the wrong
|
|
// agent or failing to authenticate. The only "synthesize minimal" case
|
|
// kept is a fresh install where the CLI reports a path but no file exists
|
|
// — there is no user data to lose in that case.
|
|
func prepareOpenclawConfig(envRoot, workDir string, opts OpenclawConfigPrep) (OpenclawConfigResult, error) {
|
|
bin := opts.OpenclawBin
|
|
if bin == "" {
|
|
bin = "openclaw"
|
|
}
|
|
timeout := opts.Timeout
|
|
if timeout <= 0 {
|
|
timeout = openclawCLITimeout
|
|
}
|
|
|
|
activePath, exists, err := openclawActiveConfigPath(bin, timeout)
|
|
if err != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("locate openclaw active config: %w", err)
|
|
}
|
|
|
|
var resolvedList []any
|
|
if exists {
|
|
resolvedList, err = openclawResolvedAgentsList(bin, timeout)
|
|
if err != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("read openclaw agents.list: %w", err)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// **Strict replace for managed mcp_config.** When the agent has a managed
|
|
// set, deep-merging the wrapper's `mcp.servers` against the user's active
|
|
// config via `$include` would let user-only entries leak in (and an empty
|
|
// managed set would not actually clear inherited servers). To enforce the
|
|
// Codex-style "managed wins, user globals invisible" contract, fetch the
|
|
// user's resolved config, drop just the `mcp.servers` map (keep other
|
|
// `mcp.*` settings like `sessionIdleTtlMs`), write a sanitized snapshot
|
|
// in envRoot, and $include the snapshot instead of the live user file.
|
|
// The wrapper's `mcp.servers` then becomes the only MCP server definition
|
|
// the snapshot's resolution can yield, while the user's surrounding `mcp`
|
|
// tuning still flows through.
|
|
snapshotPath := ""
|
|
if hasManagedMcp && exists {
|
|
resolved, ferr := openclawResolvedFullConfig(bin, timeout)
|
|
if ferr != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("read openclaw resolved config: %w", ferr)
|
|
}
|
|
if resolved == nil {
|
|
// CLI reports the file exists but `config get --json` returned
|
|
// nothing structured. Treat as no user-config-to-strip: the
|
|
// wrapper will carry managed mcp.servers as the sole source.
|
|
exists = false
|
|
activePath = ""
|
|
} else {
|
|
stripUserMcpServers(resolved)
|
|
snapBytes, merr := json.MarshalIndent(resolved, "", " ")
|
|
if merr != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("marshal openclaw user snapshot: %w", merr)
|
|
}
|
|
snapshotPath = filepath.Join(envRoot, openclawUserSnapshotFile)
|
|
// 0o600 — the snapshot is now a flat copy of the user's resolved
|
|
// config and may carry API keys / model-provider tokens that
|
|
// $include used to keep on disk in the user's own file. Lock the
|
|
// snapshot to the daemon owner; only the openclaw child reads it.
|
|
if werr := os.WriteFile(snapshotPath, snapBytes, 0o600); werr != nil {
|
|
return OpenclawConfigResult{}, fmt.Errorf("write openclaw user snapshot: %w", werr)
|
|
}
|
|
}
|
|
}
|
|
|
|
cfg := buildPerTaskOpenclawConfig(activePath, exists, snapshotPath, resolvedList, workDir, managedMcp, hasManagedMcp)
|
|
|
|
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}
|
|
if snapshotPath != "" {
|
|
// Sanitized snapshot lives in envRoot alongside the wrapper, so the
|
|
// $include never crosses directories — daemon does not need to grant
|
|
// an extra OPENCLAW_INCLUDE_ROOTS entry.
|
|
} else if exists {
|
|
// Live user config is in its own directory; tell the daemon to grant
|
|
// it so OpenClaw's include-confinement check passes.
|
|
result.IncludeRoot = filepath.Dir(activePath)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// buildPerTaskOpenclawConfig assembles the wrapper map that goes on disk.
|
|
//
|
|
// Exists=true: emit a $include link to the user's active config plus the
|
|
// workspace overrides as siblings. OpenClaw deep-merges sibling object keys
|
|
// after includes, so agents.defaults.workspace lands correctly. The
|
|
// agents.list override is emitted as a full replacement carrying every
|
|
// field of every resolved entry (id, model, prompts, tools, …) verbatim
|
|
// with only `workspace` rewritten — this is robust regardless of whether
|
|
// the runtime merges the sibling array or replaces it, because either way
|
|
// the resulting list is shape-equivalent to the user's minus workspace.
|
|
//
|
|
// Exists=false: a fresh install with no on-disk config. Emit a minimal
|
|
// config containing only the workspace override. There is no user data to
|
|
// $include here, so this is not the silent-fallback case the reviewer
|
|
// flagged.
|
|
//
|
|
// snapshotPath, when non-empty, points at a sanitized copy of the user's
|
|
// resolved config (mcp stripped) sitting in envRoot. It is the $include
|
|
// target whenever the agent has a managed mcp_config — the live user file
|
|
// would otherwise leak global `mcp.servers` past the wrapper. When
|
|
// snapshotPath is empty the wrapper falls back to $include'ing the active
|
|
// path so secrets / nested includes stay in the user's own file (no
|
|
// managed mcp means there is nothing to enforce strictness against).
|
|
//
|
|
// hasManagedMcp distinguishes "agent has a managed mcp_config (possibly an
|
|
// empty set)" from "agent inherits the user's global mcp.servers". When
|
|
// true we pin `mcp.servers` to managedMcp on the wrapper. Because the
|
|
// snapshot $include has already dropped the user's `mcp` block, the
|
|
// resulting view of `mcp.servers` is exactly the managed set — including
|
|
// `{}` for "admin saved no servers" (mirrors `hasManagedCodexMcpConfig`).
|
|
func buildPerTaskOpenclawConfig(activePath string, exists bool, snapshotPath string, resolvedList []any, workDir string, managedMcp map[string]any, hasManagedMcp bool) map[string]any {
|
|
agents := map[string]any{
|
|
"defaults": map[string]any{"workspace": workDir},
|
|
}
|
|
if rewritten := rewriteAgentsListWorkspaces(resolvedList, workDir); rewritten != nil {
|
|
agents["list"] = rewritten
|
|
}
|
|
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 snapshot $include has already dropped the
|
|
// user's `mcp` block, so this becomes the only definition.
|
|
servers := managedMcp
|
|
if servers == nil {
|
|
servers = map[string]any{}
|
|
}
|
|
cfg["mcp"] = map[string]any{"servers": servers}
|
|
}
|
|
switch {
|
|
case snapshotPath != "":
|
|
// Sanitized snapshot path; strict-replace flow for managed mcp_config.
|
|
// Array form so OpenClaw deep-merges the snapshot's content with our
|
|
// sibling keys (agents overrides, mcp.servers) rather than letting the
|
|
// include replace the whole wrapper.
|
|
cfg["$include"] = []any{snapshotPath}
|
|
case exists:
|
|
cfg["$include"] = []any{activePath}
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// stripUserMcpServers removes only `mcp.servers` from a resolved user
|
|
// config, leaving every other key under `mcp` (e.g. `sessionIdleTtlMs`)
|
|
// intact. The wrapper's managed `mcp.servers` becomes the sole server
|
|
// definition while the user's surrounding MCP tuning still applies — see
|
|
// https://docs.openclaw.ai/gateway/configuration-reference#mcp for the
|
|
// full list of sibling settings the snapshot should preserve.
|
|
//
|
|
// If the resulting `mcp` block has no keys left, the parent `mcp` key is
|
|
// dropped too so the snapshot doesn't carry an empty placeholder. Any
|
|
// non-object value for `mcp` is left as-is; we only know how to strip
|
|
// servers from the documented object shape.
|
|
func stripUserMcpServers(resolved map[string]any) {
|
|
mcp, ok := resolved["mcp"].(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
delete(mcp, "servers")
|
|
if len(mcp) == 0 {
|
|
delete(resolved, "mcp")
|
|
}
|
|
}
|
|
|
|
// openclawActiveConfigPath runs `openclaw config file` to discover the path
|
|
// the openclaw CLI considers active. Returns (absolutePath, exists, error).
|
|
//
|
|
// The CLI handles the full resolution chain — OPENCLAW_CONFIG_PATH, the
|
|
// state directory (OPENCLAW_STATE_DIR / OPENCLAW_HOME / default), legacy
|
|
// migration, and `~` expansion — so we don't re-implement it here.
|
|
//
|
|
// The reported path uses `~` shorthand for the user's home; we expand it
|
|
// so the $include reference we write is unambiguous absolute.
|
|
func openclawActiveConfigPath(bin string, timeout time.Duration) (string, bool, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
out, err := openclawExec(ctx, bin, "config", "file")
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
path := strings.TrimSpace(out)
|
|
if path == "" {
|
|
return "", false, fmt.Errorf("`openclaw config file` returned empty output")
|
|
}
|
|
if path == "~" || strings.HasPrefix(path, "~/") {
|
|
home, herr := os.UserHomeDir()
|
|
if herr != nil {
|
|
return "", false, fmt.Errorf("expand `~` in openclaw config path: %w", herr)
|
|
}
|
|
if path == "~" {
|
|
path = home
|
|
} else {
|
|
path = filepath.Join(home, strings.TrimPrefix(path, "~/"))
|
|
}
|
|
}
|
|
if !filepath.IsAbs(path) {
|
|
return "", false, fmt.Errorf("openclaw reported non-absolute config path %q", path)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return path, 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 path, true, nil
|
|
}
|
|
|
|
// openclawResolvedFullConfig fetches the user's fully resolved openclaw
|
|
// config via `openclaw config get --json` (no key path — root). The CLI's
|
|
// loader handles JSON5 / $include / env-substitution and emits a flat JSON
|
|
// object, which is what we need to write a sanitized snapshot that the
|
|
// wrapper can $include without inheriting the user's `mcp.servers`.
|
|
//
|
|
// Returns (nil, nil) when the CLI prints empty / null output for the root
|
|
// — interpreted as "no resolvable user config" by the caller, which then
|
|
// falls through to the fresh-install code path. Any other failure
|
|
// surfaces as an error so the daemon fails closed instead of silently
|
|
// degrading to a leaky non-strict wrapper.
|
|
func openclawResolvedFullConfig(bin string, timeout time.Duration) (map[string]any, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
out, err := openclawExec(ctx, bin, "config", "get", "--json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
trimmed := strings.TrimSpace(out)
|
|
if trimmed == "" || trimmed == "null" {
|
|
return nil, nil
|
|
}
|
|
var cfg map[string]any
|
|
if err := json.Unmarshal([]byte(trimmed), &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse `openclaw config get --json` output: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// openclawResolvedAgentsList fetches the user's resolved agents.list via
|
|
// `openclaw config get agents.list --json`. The CLI returns the post-
|
|
// include, post-env-substitution view of the array, which is exactly the
|
|
// shape we need to rewrite each entry's workspace.
|
|
//
|
|
// Returns nil (not an error) when agents.list is unset.
|
|
func openclawResolvedAgentsList(bin string, timeout time.Duration) ([]any, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
out, err := openclawExec(ctx, bin, "config", "get", "agents.list", "--json")
|
|
if err != nil {
|
|
if isOpenclawKeyMissing(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
trimmed := strings.TrimSpace(out)
|
|
if trimmed == "" || trimmed == "null" {
|
|
return nil, nil
|
|
}
|
|
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, 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
|
|
|
|
// execOpenclawCLI executes an openclaw subcommand and returns its stdout.
|
|
// 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.
|
|
func execOpenclawCLI(ctx context.Context, bin string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
cmd.Env = os.Environ()
|
|
var stderr strings.Builder
|
|
cmd.Stderr = &stderr
|
|
raw, err := cmd.Output()
|
|
if err != nil {
|
|
stderrMsg := strings.TrimSpace(stderr.String())
|
|
if stderrMsg != "" {
|
|
return "", fmt.Errorf("openclaw %s: %w (stderr: %s)", strings.Join(args, " "), err, stderrMsg)
|
|
}
|
|
return "", fmt.Errorf("openclaw %s: %w", strings.Join(args, " "), err)
|
|
}
|
|
return string(raw), 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
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "No value at ") ||
|
|
strings.Contains(msg, "not set") ||
|
|
strings.Contains(msg, "missing key") ||
|
|
strings.Contains(msg, "Path not found")
|
|
}
|