Files
multica/server/internal/daemon/execenv/openclaw_config.go
Multica Eve a0ef1a43f3 fix(daemon): diagnose silent OpenClaw npm shim failures on Windows (MUL-5422) (#6084)
* fix(daemon): diagnose silent OpenClaw npm shim failures on Windows (MUL-5422)

#6061 reported every OpenClaw task failing in execenv prep on Windows with
a bare `exit status 1` and no stderr, leaving the user nothing to act on.

An npm-installed `openclaw.cmd` is a batch shim that re-execs OpenClaw's
`openclaw.mjs` entrypoint through `node`, resolved from PATH. The daemon pins
`openclaw` to an absolute path, so the shim always looks correct — but that
interpreter lookup is a second, invisible resolution step that can fail on its
own. The reporter had to run their own subprocess experiments to find it.

Enrich the error instead of guessing at a fix: when a `.cmd`/`.bat` shim exits
non-zero with no stderr, report whether the interpreter resolves. Both
directions are useful — missing names the likely cause with a next step,
present clears PATH of blame and points at the remaining hypotheses (PATH
drift between the runtime `--version` gate and task prep, or a broken install).

Deliberately NOT included: rebuilding or freezing a Windows PATH. The
version-probe gate (probeBuiltinRuntime skips a provider whose `--version`
fails) and the prep helper both inherit the same daemon environment, so a
daemon that could not resolve `node` would never have registered OpenClaw at
all. That contradiction is unresolved, and a boot-time PATH snapshot would also
fight the MUL-4486 self-heal design, which re-resolves per attempt on purpose.
This change collects the evidence needed to settle it.

- Error text only; no control flow change, and real stderr still wins.
- PATH summarised as an entry count, never dumped, so daemon logs and pasted
  bug reports carry no environment detail.
- Tests: shim detection (case, spaces, Unicode), both diagnostic directions,
  out-of-scope no-ops (timeout, missing binary, native exe), and end-to-end
  through execOpenclawCLI. A windows-tagged file reproduces a real npm shim
  with and without node on PATH, and pins TEMP/TMP as not load-bearing — the
  originally reported root cause, since retracted upstream.
- New scoped step in the existing ci.yml windows-execenv job.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): address review on OpenClaw shim diagnostics (MUL-5422)

Four must-fix items from PR review, each verified against the real behaviour
rather than assumed.

1. Timeout was misdiagnosed as a missing interpreter. openclawCLITimeout kills
   the child via CommandContext, and a killed process surfaces as
   *exec.ExitError ("signal: killed") — the same type a genuine exit 1
   produces. The errors.As gate accepted it and appended "install Node.js",
   sending users to fix something that was never broken. execOpenclawCLI now
   attributes ctx.Err() before consulting the diagnostic. Confirmed locally:
   `signal: killed`, errors.As(*exec.ExitError)=true, ctx.Err() set. The old
   test passed context.DeadlineExceeded directly and so never saw the real
   shape; replaced with a genuine CommandContext timeout regression on both
   Linux and Windows.

2. The interpreter lookup did not match npm's. npm's cmd-shim template emits
   `IF EXIST "%dp0%\node.exe" (...) ELSE ( SET "_prog=node" )`, so a co-located
   node.exe wins over PATH entirely. Checking only LookPath reported "node is
   not resolvable" for installs that actually run fine — confidently wrong,
   which is worse than silence. Now resolves co-located `node.exe`/`node`
   first, then PATH, and reports which. Wording is also conditional now
   ("if <name> is an npm-generated shim"): a batch extension does not prove npm
   authorship, since MULTICA_OPENCLAW_PATH can point at any batch file.

3. The message leaked local paths off-box. On prep failure this text is not
   log-local — it travels reportTerminalTask → Client.FailTask and is persisted
   server-side as the task error, so an absolute Windows shim path uploads the
   account name and install layout. Now reports only the shim's base name,
   whether the interpreter resolved and from where, and a PATH entry count.
   Never an absolute path, never PATH contents.

4. Windows CI was green without exercising the new code. The job log showed
   `cmd.exe stderr DID reach Go's pipe` with `'node' is not recognized`, so the
   missing-node case takes the existing stderr branch and the diagnostic never
   ran — masked by an "either branch passes" assertion. That disjunction is
   gone: the missing-node test now asserts the observed stderr behaviour
   (disproving #6061's premise), and a new test drives a genuinely silent shim
   to prove the diagnostic branch itself works on Windows. Also added Windows
   coverage for the co-located interpreter and the timeout case.

The windows-tagged shim is now npm's real generated template rather than a
hand-simplified `node ...` one-liner, so the co-located branch is reproduced
faithfully instead of hidden.

Co-authored-by: multica-agent <github@multica.ai>

* test(daemon): make the OpenClaw timeout regression PATH-independent

The new timeout test stripped PATH (so a stray interpreter lookup would report
"missing") while its hanging shim invoked `sleep` through a PATH lookup. macOS
`sh` quietly falls back to a default PATH so this passed locally; dash on Linux
does not, so CI failed with `exit status 127 (stderr: sleep: not found)` — the
shim died instantly instead of hanging, and the assertion never saw a timeout.

Resolve `sleep` before PATH is stripped and embed it by absolute path, so the
shim needs no PATH of its own. Verified the failure mode and the fix directly:
`env -i /bin/sh -c 'PATH=/nonexistent; sleep 0.05'` reproduces
"sleep: command not found", while the absolute path runs fine with the same
empty PATH.

Windows is skipped here and covered by TestWindowsOpenclawShimTimeoutIsNotMisdiagnosed,
which has a real cmd.exe host and a System32 PATH that can resolve its own helper.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): bound execOpenclawCLI so its 5s timeout is actually enforceable

The new timeout regression exposed a real bug in the code it was testing, not
just a flaky test: openclawCLITimeout could not bound the call at all.

CommandContext kills only the direct child, and cmd.Output() blocks in Wait()
until the stdout pipe closes. Any grandchild that inherited stdout keeps the
call parked for its own lifetime. Verified on linux/dash: a shim whose child
slept 5s ran the FULL 5.01s against a 150ms deadline. With a WaitDelay backstop
the same case returns in ~2.17s.

This is not a hypothetical shape — it is precisely an npm shim on Windows
(cmd.exe → node), so a wedged node could stall task prep far past the 5s cap
that comment claims. detectCLIVersion already carries this exact backstop for
the `--version` probe for the same reason; execOpenclawCLI now matches it.

Also corrected the test comment: an earlier revision claimed a trailing
`exit 0` was needed to force the grandchild. Docker showed otherwise — dash
hangs either way and macOS reproduces neither, which is why CI caught this and
local runs did not. The comment now records the measured behaviour.

Verified in a linux/dash container (the CI platform, not just macOS): the full
execenv package passes with -race, and the timeout case takes 2.17s.

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): drop the WaitDelay change and wrap the context error (MUL-5422)

Round-2 review: take option 1 — keep this PR to diagnostics and split the
timeout/process-tree work out.

The reviewer is right that WaitDelay traded a hang for a process leak, and I
had the mechanism wrong. Measured on linux/dash by recording the grandchild PID
and reading /proc/<pid>/stat at the moment Output returns:

  no WaitDelay:   elapsed 6.01s (6s sleep, 150ms deadline), grandchild state Z
  with WaitDelay: elapsed 2.17s (60s sleep, 150ms deadline), grandchild state S

So without WaitDelay the call is hostage to the descendant's lifetime but no
live process is left behind — it returned precisely because the descendant had
exited. With WaitDelay the call is bounded but a live descendant survives. My
earlier claim that the orphan pre-existed was an artifact of a sleep duration
that happened to equal the return time.

Go's WaitDelay contract covers killing the direct child and closing our pipe
ends; it does not reap orphans. Closing this properly needs process-tree
ownership (Unix process group, Windows Job Object) so the deadline can terminate
the whole tree — and on Unix nothing else will, since
preparationProcessController.finish() is a no-op there (isolation_unix.go).
That is its own change with its own risk surface, so it is tracked separately
and openclawCLITimeout now documents the gap with the measurements rather than
shipping half a fix.

Also fixes the round-2 nit: the context branch %w-wrapped the process error
while printing ctxErr with %v, so errors.Is(err, context.DeadlineExceeded) was
false despite the text containing it. The context error is now the wrapped
cause and the process error is attached for diagnosis:

  openclaw config file: context deadline exceeded (process: signal: killed)

Tests: the timeout cases no longer depend on WaitDelay and no longer leave a
live process — short sleeps keep them about attribution, which is what they are
for. Added an explicit errors.Is assertion for both DeadlineExceeded and
Canceled. Verified in a linux/dash container (the CI platform): full execenv
package passes with -race and `ps` shows no leftover sleep processes.

Co-authored-by: multica-agent <github@multica.ai>

* docs(daemon): correct two stale comments on the OpenClaw CLI timeout (MUL-5422)

Both nits from the third review. Comment-only; no code change.

1. openclawCLITimeout's doc contradicted itself — it opened with "caps ...
   without letting a hung CLI stall task dispatch indefinitely" and then
   explained that the deadline cannot actually bound the call. Reworded to say
   what it is (a 5s context deadline) and to point at the gap rather than assert
   a guarantee it does not provide. Also names MUL-5467 instead of the vague
   "tracked separately".

2. The two timeout tests claimed a long wait would "leave a live process
   behind". That described the reverted WaitDelay behaviour, not the current
   code. Without WaitDelay, cmd.Output() returns only once the descendant has
   closed stdout — its exit is what produces the EOF — so a long wait makes the
   test slow, it does not leak. Re-verified on linux/dash after the fix: the
   case takes 1.01s for a 1s sleep and `ps` shows no leftover process, and the
   earlier PID probe recorded the grandchild in state Z at the return point.

Rebased onto c25a82eee.

Co-authored-by: multica-agent <github@multica.ai>

* docs(daemon): tighten OpenClaw timeout wording and pipe-EOF claims (MUL-5422)

Round-4 review nits. Comment-only; verified no non-comment line changed.

1. OpenclawConfigPrep.Timeout still said it "caps each CLI invocation", which
   contradicts the openclawCLITimeout doc corrected last round. It now says it
   sets the context deadline and points at that note. Fixed the same word in the
   struct's own doc comment, which had the identical claim.

2. The two timeout-test comments equated "descendant closes the pipe" with
   "descendant has exited". That holds for these helpers but is not a general
   property — a process can close its pipes and keep running — so the comments
   now scope the claim to the helper and say so explicitly.

   Also corrected "stdout" to the output pipes os/exec manages for both stdout
   AND stderr. Verified rather than assumed: with cmd.Stderr set to an
   in-memory writer (as execOpenclawCLI does), a grandchild holding EITHER
   stream parks cmd.Output() for its full 3s lifetime, while one holding
   neither returns in 0s. So Wait genuinely depends on both.

No rebase this round: the branch is 1 commit behind main, that commit does not
touch execenv, and GitHub already reports MERGEABLE — not worth another forced
CI rerun.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-29 16:30:35 +08:00

919 lines
38 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 is the context deadline set on each `openclaw config ...`
// invocation during task setup. The CLI is fast (<200ms normal); 5s leaves
// headroom for a cold node start.
//
// It is a deadline, not a guaranteed cap — see the gap below.
//
// Known gap (deliberately not fixed here): this deadline does not actually
// bound the call when the CLI leaves a descendant holding stdout.
// CommandContext kills only the direct child, and cmd.Output() blocks in
// Wait() until the stdout pipe closes, so the call runs for the descendant's
// lifetime. Measured on linux/dash: a shim whose backgrounded child slept 6s
// took 6.01s against a 150ms deadline. An npm shim is that shape on Windows
// (cmd.exe → node).
//
// A cmd.WaitDelay backstop bounds the call but leaves the descendant running
// (measured: returns in 2.17s with the grandchild still in state S), trading a
// hang for a process leak — and on Unix nothing reaps it, because
// preparationProcessController.finish() is a no-op there. Closing this properly
// needs process-tree ownership (Unix process group, Windows Job Object) so the
// deadline can terminate the whole tree, which is its own change with its own
// risk surface. Tracked in MUL-5467; this file intentionally keeps the existing
// behaviour rather than shipping half of it.
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
// 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 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
// 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
}
// 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 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.
// For OpenClaw releases whose `config` command rejects the `file`
// subcommand shape, fall back to resolving OpenClaw's active-config
// candidates, including legacy Clawdbot/Moltbot/Moldbot locations.
// 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
var agentsFromRegistry bool
if exists {
resolvedList, agentsFromRegistry, 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, agentsFromRegistry, 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}
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, agentsFromRegistry bool, workDir string, managedMcp map[string]any, hasManagedMcp bool, gateway OpenclawGatewayPin) map[string]any {
agents := map[string]any{
"defaults": map[string]any{"workspace": workDir},
}
// Only write per-agent overrides back to the wrapper when they came from
// the config-schema `agents.list` path (pre-2026.6). A registry-sourced
// list (OpenClaw 2026.6.x+) is NOT valid `agents.list[]` config — the
// schema validator rejects it ("agents.list.0: Invalid input") and fails
// closed before the agent runs. 2026.6.x has no in-config path for per-
// agent workspace pinning, so `agents.defaults.workspace` (set above) is
// the only knob, and it is sufficient: OpenClaw applies it to the agent it
// selects from the registry (see upstream #3028, write-side half).
if !agentsFromRegistry {
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}
}
// 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
}
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
}
// 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
}
// 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 — explicit config path, state
// directory, OPENCLAW_HOME / default home, legacy locations, migration, and `~`
// expansion — so we prefer it when the installed CLI supports the command.
//
// OpenClaw 2026.2.x briefly rejected `openclaw config file` with the generic
// "too many arguments for 'config'" error. For that command-shape failure only,
// fall back to the same active-config candidate shape so task prep can still
// continue without losing upgraded users' legacy config files.
//
// 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 {
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)
}
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
}
func expandOpenclawPath(path string) (string, error) {
if path == "~" || strings.HasPrefix(path, "~/") {
home, herr := os.UserHomeDir()
if herr != nil {
return "", 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) {
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"))
}
// 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 per-agent list and
// reports which schema produced it. The schema matters downstream: a config-
// sourced list is itself valid `agents.list[]` config and may be written back
// into the wrapper to pin per-agent workspaces, whereas a registry-sourced
// list MUST NOT be written back — see openclawRegistryAgentsList.
//
// Two 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. fromRegistry=false.
// - 2026.6.x and later: `agents.list` is no longer a config path — 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*. fromRegistry=true.
//
// Returns (nil, false, nil) when neither source yields any agents.
func openclawResolvedAgentsList(bin string, timeout time.Duration) ([]any, bool, 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) {
// New schema: the config path is gone; the agents live in the
// sqlite registry. Resolve them via the subcommand instead.
list, rerr := openclawRegistryAgentsList(bin, timeout)
return list, true, rerr
}
return nil, false, err
}
trimmed := strings.TrimSpace(out)
if trimmed == "" || trimmed == "null" {
return nil, false, nil
}
var list []any
if err := json.Unmarshal([]byte(trimmed), &list); err != nil {
return nil, false, fmt.Errorf("parse `openclaw config get agents.list --json` output: %w", err)
}
return list, false, 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 — there is no in-config way to pin a per-agent
// workspace. The per-task workspace is instead 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). Callers gate the write-back on fromRegistry from
// openclawResolvedAgentsList.
//
// 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, 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 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.
//
// 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) {
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 ctxErr := ctx.Err(); ctxErr != nil {
if stderrMsg != "" {
return "", fmt.Errorf("openclaw %s: %w (process: %v; stderr: %s)", strings.Join(args, " "), ctxErr, err, stderrMsg)
}
return "", fmt.Errorf("openclaw %s: %w (process: %v)", strings.Join(args, " "), ctxErr, err)
}
if stderrMsg != "" {
return "", fmt.Errorf("openclaw %s: %w (stderr: %s)", strings.Join(args, " "), err, stderrMsg)
}
if diag := openclawShimDiagnostic(bin, err); diag != "" {
return "", fmt.Errorf("openclaw %s: %w (%s)", strings.Join(args, " "), err, diag)
}
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
}
// 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(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")
}
// 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
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unknown command") ||
strings.Contains(msg, "unknown option") ||
strings.Contains(msg, "does not recognize") ||
strings.Contains(msg, "unknown argument")
}