mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 12:35:35 +02:00
* feat(execenv): native OpenClaw skill discovery via per-task config
MUL-2213 stopped lying about native discovery and routed openclaw skills
to .agent_context/skills/ — a path openclaw's scanner never reads.
Multica skills attached to openclaw-backed agents were still invisible to
the runtime; the AGENTS.md fallback was only a documentation patch.
OpenClaw's skill scanner walks <workspaceDir>/skills/ (plus a few other
roots), and workspaceDir is resolved from the openclaw config file —
specifically agents.list[id].workspace → agents.defaults.workspace →
~/.openclaw/workspace. There is no CLI flag or env var override on the
agent runtime; the only knob is the config file.
This change wires a per-task synthesized config:
1. execenv.prepareOpenclawConfig deep-copies the user's existing
openclaw.json (priority: $OPENCLAW_CONFIG_PATH, else
~/.openclaw/openclaw.json), rewrites agents.defaults.workspace AND
every agents.list[].workspace to the task workdir, and writes the
result to {envRoot}/openclaw-config.json. Provider sections,
registered agents, model providers, gateway settings — everything
openclaw needs to actually start — are preserved as-is.
2. resolveSkillsDir for "openclaw" now points at {workDir}/skills/,
which is the first path openclaw scans under workspaceDir. Skills
written here are picked up natively.
3. daemon.go exports OPENCLAW_CONFIG_PATH={env.OpenclawConfigPath} on
the openclaw subprocess and adds OPENCLAW_CONFIG_PATH to the
custom_env blocklist so users cannot accidentally override it.
4. buildMetaSkillContent now lists openclaw alongside the
"discovered automatically" providers; the .agent_context/skills/
fallback line stays for gemini/hermes.
The new regression test TestPrepareOpenclawSkillWriteMatchesScanPath is
the one MUL-2219's DoD calls out: it resolves the workspaceDir the way
openclaw does (reading agents.defaults.workspace out of the synthesized
config) and proves {workspaceDir}/skills/<name>/SKILL.md is what Multica
actually wrote. The pre-MUL-2219 fix asserted "we wrote a file" without
checking the scanner would ever see it — which is how the dead drop into
.openclaw/skills/ landed in #2621's first commit.
Verified locally: minimum-viable synthesized config validates via
`openclaw config validate`, and `OPENCLAW_CONFIG_PATH=<path> openclaw
config get agents.defaults.workspace` returns the task workdir as
expected. MUL-2219
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): delegate openclaw config parsing to CLI and fail closed
Address Elon's must-fix on PR #2628: the previous implementation parsed
~/.openclaw/openclaw.json with encoding/json, which cannot read JSON5
or follow $include — the OpenClaw spec's actual format. When parsing
failed, prepareOpenclawConfig silently emitted a minimal config, which
could boot OpenClaw without the user's registered agents, model
providers, or API keys.
Two changes:
1. Delegate active-config-path resolution and config reading to the
openclaw CLI itself. `openclaw config file` locates the active
config (covering OPENCLAW_CONFIG_PATH / OPENCLAW_STATE_DIR /
OPENCLAW_HOME / default and the legacy chain), and the wrapper we
write uses $include to point at it so OpenClaw's own loader handles
JSON5, $include nesting, env-substitution, and secret refs. We read
only agents.list via `openclaw config get --json` to rewrite each
entry's workspace — secrets, comments, and includes in the user
config are never touched.
2. Remove the silent minimal-config fallback. Any CLI failure,
malformed output, or write error now surfaces as a hard error from
Prepare / Reuse. The only "synthesize minimal" path left is a fresh
install (CLI reports a path but the file doesn't exist), where
there is no user data to lose.
The per-task override still rewrites every agents.list[].workspace,
not just agents.defaults.workspace — this is intentional task
isolation, documented in prepareOpenclawConfig and the PR body. A
host-scope per-agent workspace would otherwise silently route the
scanner back to the user's shared workspace.
Cleanups Elon flagged in the same review:
- daemon.go inline-system-prompt comment no longer claims openclaw
ignores the task workdir; it does load it now, and the inline brief
is a belt-and-suspenders carryover for older releases.
- execenv.go openclaw block no longer references "skill file paths in
the inline brief" — the brief uses "discovered automatically".
Reuse() switches to a ReuseParams struct so the openclaw binary path
threads through alongside CodexVersion without a 6th positional arg.
MUL-2219
Co-authored-by: multica-agent <github@multica.ai>
* fix(execenv): grant OpenClaw $include cross-dir confinement for per-task wrapper
The per-task wrapper at envRoot/openclaw-config.json $includes the user's
active config (typically ~/.openclaw/openclaw.json), but OpenClaw confines
$include resolution to the wrapper file's directory unless the target's
parent is granted via OPENCLAW_INCLUDE_ROOTS. Without this, OpenClaw refuses
to follow the link at runtime and the wrapper boots with no user-registered
agents.
prepareOpenclawConfig now returns dirname(activePath) as IncludeRoot, and
the daemon prepends it to whatever the user already has in
OPENCLAW_INCLUDE_ROOTS via the new composeOpenclawIncludeRoots helper
(dedupes, drops empty segments, preserves user-configured roots). Fresh
install emits no $include and leaves the env var untouched.
Adds OPENCLAW_INCLUDE_ROOTS to the custom_env blocklist so a per-agent
override cannot strip the granted root.
Regression tests:
- TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement asserts
every $include target's dirname is covered by the IncludeRoot we surface.
- TestPrepareEnvironmentOpenclawWiresIncludeRoot covers the non-fresh-install
Environment wiring.
- TestComposeOpenclawIncludeRoots covers the daemon-side env composition
(preserve, dedupe, drop empties).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: multica-agent <github@multica.ai>
626 lines
24 KiB
Go
626 lines
24 KiB
Go
package execenv
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// openclawCLIStub captures one or more (subcommand, response) pairs and
|
|
// installs itself into the package-level openclawExec hook for the duration
|
|
// of a test. Each call records the args it saw so assertions can verify the
|
|
// preparer hit `config file` and `config get agents.list --json`.
|
|
type openclawCLIStub struct {
|
|
t *testing.T
|
|
bin string
|
|
responses map[string]openclawResponse
|
|
calls []openclawCall
|
|
}
|
|
|
|
type openclawCall struct {
|
|
bin string
|
|
args []string
|
|
}
|
|
|
|
type openclawResponse struct {
|
|
stdout string
|
|
err error
|
|
}
|
|
|
|
func installOpenclawStub(t *testing.T, responses map[string]openclawResponse) *openclawCLIStub {
|
|
t.Helper()
|
|
stub := &openclawCLIStub{
|
|
t: t,
|
|
bin: "/test/stub/openclaw",
|
|
responses: responses,
|
|
}
|
|
prev := openclawExec
|
|
openclawExec = stub.exec
|
|
t.Cleanup(func() { openclawExec = prev })
|
|
return stub
|
|
}
|
|
|
|
func (s *openclawCLIStub) exec(_ context.Context, bin string, args ...string) (string, error) {
|
|
s.calls = append(s.calls, openclawCall{bin: bin, args: append([]string(nil), args...)})
|
|
key := strings.Join(args, " ")
|
|
resp, ok := s.responses[key]
|
|
if !ok {
|
|
return "", fmt.Errorf("openclawCLIStub: unexpected args %q", key)
|
|
}
|
|
return resp.stdout, resp.err
|
|
}
|
|
|
|
func mustReadJSON(t *testing.T, path string) map[string]any {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read synthesized cfg: %v", err)
|
|
}
|
|
var got map[string]any
|
|
if err := json.Unmarshal(raw, &got); err != nil {
|
|
t.Fatalf("parse synthesized cfg: %v", err)
|
|
}
|
|
return got
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigDelegatesParsingToCLI is the headline assertion
|
|
// for the Elon must-fix: instead of re-parsing the user's openclaw.json
|
|
// with encoding/json (which can't read JSON5 / $include / env-var
|
|
// substitution), we delegate the read to the openclaw CLI. The wrapper
|
|
// $includes the user's active path so OpenClaw's own loader handles the
|
|
// JSON5 / $include resolution; we only emit workspace overrides.
|
|
func TestPrepareOpenclawConfigDelegatesParsingToCLI(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
|
|
// JSON5 user config — comments and trailing commas would break the old
|
|
// encoding/json reader. The stub doesn't actually parse this; it just
|
|
// proves the wrapper points the $include at the right file regardless
|
|
// of its on-disk syntax.
|
|
userConfigDir := t.TempDir()
|
|
userConfigPath := filepath.Join(userConfigDir, "openclaw.json")
|
|
json5Body := `// User config with JSON5 features the old parser couldn't read
|
|
{
|
|
agents: {
|
|
defaults: {
|
|
workspace: "/Users/alice/.openclaw/workspace",
|
|
model: { primary: "anthropic/claude-sonnet-4-6" },
|
|
},
|
|
list: [
|
|
{ id: "scout", workspace: "/Users/alice/projects/scout", },
|
|
{ id: "coder", model: "openai/gpt-5", },
|
|
],
|
|
},
|
|
gateway: { port: 18789 }, // trailing comma
|
|
}
|
|
`
|
|
if err := os.WriteFile(userConfigPath, []byte(json5Body), 0o600); err != nil {
|
|
t.Fatalf("write user cfg: %v", err)
|
|
}
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: userConfigPath + "\n"},
|
|
"config get agents.list --json": {stdout: `[
|
|
{ "id": "scout", "workspace": "/Users/alice/projects/scout" },
|
|
{ "id": "coder", "model": "openai/gpt-5" }
|
|
]`},
|
|
})
|
|
|
|
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
cfgPath := result.ConfigPath
|
|
if cfgPath != filepath.Join(envRoot, openclawConfigFile) {
|
|
t.Errorf("cfgPath = %q, want %q", cfgPath, filepath.Join(envRoot, openclawConfigFile))
|
|
}
|
|
|
|
got := mustReadJSON(t, cfgPath)
|
|
|
|
// $include must reference the user's active config so OpenClaw's own
|
|
// loader does the JSON5 / $include / env-substitution work.
|
|
include, ok := got["$include"].([]any)
|
|
if !ok || len(include) != 1 || include[0] != userConfigPath {
|
|
t.Errorf("$include = %v, want [%q]", got["$include"], userConfigPath)
|
|
}
|
|
|
|
// The wrapper $includes a path that lives outside envRoot. OpenClaw
|
|
// confines $include resolution to the wrapper file's own directory
|
|
// unless OPENCLAW_INCLUDE_ROOTS lists the target. Surface the user
|
|
// config's dirname so the daemon can grant it.
|
|
if result.IncludeRoot != userConfigDir {
|
|
t.Errorf("IncludeRoot = %q, want %q (dirname of active config so wrapper can $include across dirs)", result.IncludeRoot, userConfigDir)
|
|
}
|
|
|
|
agents := got["agents"].(map[string]any)
|
|
defaults := agents["defaults"].(map[string]any)
|
|
if defaults["workspace"] != workDir {
|
|
t.Errorf("agents.defaults.workspace = %v, want %q", defaults["workspace"], workDir)
|
|
}
|
|
|
|
// Per-agent workspaces must be rewritten so a host-scope agents.list[].
|
|
// workspace cannot silently win over our defaults override. This is
|
|
// intentional per-task isolation (see prepareOpenclawConfig doc).
|
|
list := agents["list"].([]any)
|
|
if len(list) != 2 {
|
|
t.Fatalf("agents.list length = %d, want 2", len(list))
|
|
}
|
|
for i, item := range list {
|
|
entry := item.(map[string]any)
|
|
if entry["workspace"] != workDir {
|
|
t.Errorf("agents.list[%d].workspace = %v, want %q (per-agent overrides must be rewritten so they don't beat defaults)", i, entry["workspace"], workDir)
|
|
}
|
|
}
|
|
// Non-workspace fields per entry are carried over so a sibling-replace
|
|
// merge in OpenClaw's $include semantics doesn't silently lose them.
|
|
if list[0].(map[string]any)["id"] != "scout" {
|
|
t.Errorf("agents.list[0].id lost in carryover: %v", list[0])
|
|
}
|
|
if list[1].(map[string]any)["model"] != "openai/gpt-5" {
|
|
t.Errorf("agents.list[1].model lost in carryover: %v", list[1])
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigFailsClosedOnCLIError — the headline regression
|
|
// for Elon's review. When the openclaw CLI fails (broken config, missing
|
|
// binary, etc.), prepareOpenclawConfig MUST surface the error rather than
|
|
// silently synthesize a minimal config that would mask the user's broken
|
|
// state and boot OpenClaw without their registered agents.
|
|
func TestPrepareOpenclawConfigFailsClosedOnCLIError(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {err: errors.New("exec: openclaw: no such file or directory")},
|
|
})
|
|
|
|
_, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err == nil {
|
|
t.Fatal("prepareOpenclawConfig succeeded on CLI failure; expected fail closed")
|
|
}
|
|
if !strings.Contains(err.Error(), "locate openclaw active config") {
|
|
t.Errorf("error message %q does not name the failed step", err.Error())
|
|
}
|
|
|
|
// No stale wrapper left behind.
|
|
if _, err := os.Stat(filepath.Join(envRoot, openclawConfigFile)); !os.IsNotExist(err) {
|
|
t.Errorf("wrapper config should not exist after fail-closed; got err = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigFailsClosedOnMalformedAgentsList — the second
|
|
// fail-closed surface. When `openclaw config get agents.list --json`
|
|
// returns junk we can't parse, we fail rather than guess.
|
|
func TestPrepareOpenclawConfigFailsClosedOnMalformedAgentsList(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
|
|
userConfigPath := filepath.Join(t.TempDir(), "openclaw.json")
|
|
if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil {
|
|
t.Fatalf("write user cfg: %v", err)
|
|
}
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: userConfigPath},
|
|
"config get agents.list --json": {stdout: "<<<garbage>>>"},
|
|
})
|
|
|
|
_, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err == nil {
|
|
t.Fatal("prepareOpenclawConfig succeeded on malformed agents.list output; expected fail closed")
|
|
}
|
|
if !strings.Contains(err.Error(), "agents.list") {
|
|
t.Errorf("error message %q does not name the failed step", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigKeyMissingTreatedAsEmpty — `config get` exits
|
|
// non-zero when a path is unset. That is not a failure; the user simply has
|
|
// no agents.list. We must produce a valid wrapper with just the defaults
|
|
// override.
|
|
func TestPrepareOpenclawConfigKeyMissingTreatedAsEmpty(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
|
|
userConfigPath := filepath.Join(t.TempDir(), "openclaw.json")
|
|
if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil {
|
|
t.Fatalf("write user cfg: %v", err)
|
|
}
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: userConfigPath},
|
|
"config get agents.list --json": {err: errors.New("openclaw: No value at agents.list")},
|
|
})
|
|
|
|
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
cfgPath := result.ConfigPath
|
|
got := mustReadJSON(t, cfgPath)
|
|
if _, present := got["agents"].(map[string]any)["list"]; present {
|
|
t.Errorf("agents.list should be omitted when user has none, got %v", got["agents"])
|
|
}
|
|
if got["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"] != workDir {
|
|
t.Errorf("defaults.workspace not set when agents.list missing")
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigFreshInstallNoOnDiskConfig — the only legitimate
|
|
// "synthesize minimal" case. `openclaw config file` reports a path (the
|
|
// default) but the file does not exist yet. We emit a wrapper with the
|
|
// workspace override and NO $include (there is nothing to include).
|
|
func TestPrepareOpenclawConfigFreshInstallNoOnDiskConfig(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
|
|
// CLI reports a default path that doesn't exist (fresh install).
|
|
missingPath := filepath.Join(t.TempDir(), "openclaw.json")
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: missingPath},
|
|
// `config get` should not be called when the file does not exist;
|
|
// the stub will fail "unexpected args" if it is.
|
|
})
|
|
|
|
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
cfgPath := result.ConfigPath
|
|
got := mustReadJSON(t, cfgPath)
|
|
if _, present := got["$include"]; present {
|
|
t.Errorf("$include should be absent for fresh install, got %v", got["$include"])
|
|
}
|
|
if got["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"] != workDir {
|
|
t.Errorf("defaults.workspace not set on fresh-install wrapper")
|
|
}
|
|
// Fresh install emits no $include, so no extra include root is needed
|
|
// — the wrapper never steps outside envRoot. Daemon should leave the
|
|
// user's OPENCLAW_INCLUDE_ROOTS alone.
|
|
if result.IncludeRoot != "" {
|
|
t.Errorf("IncludeRoot = %q on fresh install, want empty (no $include emitted)", result.IncludeRoot)
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigExpandsTilde — `openclaw config file` reports
|
|
// paths with `~` shortened. The $include in our wrapper must be absolute so
|
|
// the loader resolves it unambiguously.
|
|
func TestPrepareOpenclawConfigExpandsTilde(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
|
|
fakeHome := t.TempDir()
|
|
t.Setenv("HOME", fakeHome)
|
|
if err := os.MkdirAll(filepath.Join(fakeHome, ".openclaw"), 0o755); err != nil {
|
|
t.Fatalf("mkdir home/.openclaw: %v", err)
|
|
}
|
|
realPath := filepath.Join(fakeHome, ".openclaw", "openclaw.json")
|
|
if err := os.WriteFile(realPath, []byte(`{}`), 0o600); err != nil {
|
|
t.Fatalf("write user cfg: %v", err)
|
|
}
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: "~/.openclaw/openclaw.json\n"},
|
|
"config get agents.list --json": {stdout: "null"},
|
|
})
|
|
|
|
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
cfgPath := result.ConfigPath
|
|
got := mustReadJSON(t, cfgPath)
|
|
include := got["$include"].([]any)
|
|
if include[0] != realPath {
|
|
t.Errorf("$include[0] = %v, want %q (tilde must be expanded to absolute)", include[0], realPath)
|
|
}
|
|
// IncludeRoot must also use the expanded absolute dirname, otherwise
|
|
// the daemon would export a `~/.openclaw`-shaped root that OpenClaw
|
|
// would not match against the resolved absolute include target.
|
|
wantRoot := filepath.Join(fakeHome, ".openclaw")
|
|
if result.IncludeRoot != wantRoot {
|
|
t.Errorf("IncludeRoot = %q, want %q (must be expanded absolute dirname)", result.IncludeRoot, wantRoot)
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement is the
|
|
// regression test for the Elon include-confinement blocker. OpenClaw
|
|
// resolves `$include` only inside the wrapper file's own directory unless
|
|
// the target's parent dir is granted via OPENCLAW_INCLUDE_ROOTS. The
|
|
// previous PR wrote a wrapper at envRoot that $included
|
|
// `~/.openclaw/openclaw.json` (cross-directory) but never surfaced the
|
|
// dirname; OpenClaw would have refused to follow the link at runtime.
|
|
//
|
|
// This test simulates the same confinement check OpenClaw performs:
|
|
//
|
|
// - For every `$include` target, assert filepath.Dir(target) is either
|
|
// the wrapper's own dir OR matches the IncludeRoot we surface for the
|
|
// daemon to grant.
|
|
//
|
|
// It does NOT shell out to a real openclaw binary — the spec is small and
|
|
// stable enough that mirroring it in-test is more reliable than depending
|
|
// on the CLI being installed in CI. If this assertion ever drifts from the
|
|
// real loader, the upstream docs are the source of truth:
|
|
// https://github.com/openclaw/openclaw/blob/main/docs/gateway/configuration.md
|
|
func TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
|
t.Fatalf("mkdir workdir: %v", err)
|
|
}
|
|
|
|
// User's active config sits in its own dir, not envRoot. This is the
|
|
// realistic shape (~/.openclaw/openclaw.json is never inside the task
|
|
// workspace) and is the exact case the bug paper-trail flagged.
|
|
userConfigDir := t.TempDir()
|
|
userConfigPath := filepath.Join(userConfigDir, "openclaw.json")
|
|
if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil {
|
|
t.Fatalf("write user cfg: %v", err)
|
|
}
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: userConfigPath},
|
|
"config get agents.list --json": {stdout: "null"},
|
|
})
|
|
|
|
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
|
|
got := mustReadJSON(t, result.ConfigPath)
|
|
rawIncludes, ok := got["$include"].([]any)
|
|
if !ok || len(rawIncludes) == 0 {
|
|
t.Fatalf("wrapper has no $include entries, but a user config is present: %v", got)
|
|
}
|
|
|
|
// Mirror OpenClaw's confinement check: every cross-dir $include target
|
|
// must have its dirname covered by either the wrapper's own dir or the
|
|
// IncludeRoot we surface.
|
|
wrapperDir := filepath.Dir(result.ConfigPath)
|
|
granted := []string{wrapperDir}
|
|
if result.IncludeRoot != "" {
|
|
granted = append(granted, result.IncludeRoot)
|
|
}
|
|
for _, raw := range rawIncludes {
|
|
target, ok := raw.(string)
|
|
if !ok {
|
|
t.Fatalf("$include entry is not a string: %T %v", raw, raw)
|
|
}
|
|
targetDir := filepath.Dir(target)
|
|
allowed := false
|
|
for _, g := range granted {
|
|
if targetDir == g {
|
|
allowed = true
|
|
break
|
|
}
|
|
}
|
|
if !allowed {
|
|
t.Errorf("$include target %q has dirname %q which is not in granted include roots %v — OpenClaw would refuse to load it",
|
|
target, targetDir, granted)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawSkillWriteMatchesScanPath is the regression test the
|
|
// MUL-2219 DoD calls out: the directory Multica writes skills into MUST be
|
|
// the same directory the OpenClaw scanner reads from. We assert this by
|
|
// resolving the workspaceDir the way OpenClaw does (agents.defaults.workspace
|
|
// from the synthesized config) and proving {workspaceDir}/skills/ holds the
|
|
// skill we wrote. Previous fixes asserted "we wrote a file" without checking
|
|
// the scanner would ever see it; that is why MUL-2213 / #2621 needed a
|
|
// follow-up.
|
|
func TestPrepareOpenclawSkillWriteMatchesScanPath(t *testing.T) {
|
|
envRoot := t.TempDir()
|
|
workDir := filepath.Join(envRoot, "workdir")
|
|
for _, sub := range []string{workDir, filepath.Join(envRoot, "output"), filepath.Join(envRoot, "logs")} {
|
|
if err := os.MkdirAll(sub, 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", sub, err)
|
|
}
|
|
}
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
// Fresh install — no user config on disk. Wrapper carries only the
|
|
// workspace override, which is what the scanner reads.
|
|
"config file": {stdout: filepath.Join(t.TempDir(), "absent-openclaw.json")},
|
|
})
|
|
|
|
skills := []SkillContextForEnv{
|
|
{Name: "Issue Review", Content: "Review issues thoroughly."},
|
|
{Name: "Local Dev", Content: "Spin up the local dev env."},
|
|
}
|
|
|
|
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
cfgPath := result.ConfigPath
|
|
if err := writeContextFiles(workDir, "openclaw", TaskContextForEnv{
|
|
IssueID: "issue-1",
|
|
AgentSkills: skills,
|
|
}); err != nil {
|
|
t.Fatalf("writeContextFiles: %v", err)
|
|
}
|
|
|
|
cfg := mustReadJSON(t, cfgPath)
|
|
wsDir := cfg["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"].(string)
|
|
for _, s := range skills {
|
|
want := filepath.Join(wsDir, "skills", sanitizeSkillName(s.Name), "SKILL.md")
|
|
if _, err := os.Stat(want); err != nil {
|
|
t.Errorf("openclaw scan target %s missing — Multica's write path and the openclaw scanner are out of sync: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPrepareEnvironmentOpenclawWiresConfigPath — end-to-end: Prepare sets
|
|
// env.OpenclawConfigPath so the daemon can export OPENCLAW_CONFIG_PATH, and
|
|
// the path resolves to a file with the correct workspace override. With
|
|
// fail-closed semantics, Prepare itself errors when the CLI is unavailable;
|
|
// a stub here keeps the happy path observable.
|
|
func TestPrepareEnvironmentOpenclawWiresConfigPath(t *testing.T) {
|
|
wsRoot := t.TempDir()
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: filepath.Join(t.TempDir(), "absent.json")},
|
|
})
|
|
|
|
env, err := Prepare(PrepareParams{
|
|
WorkspacesRoot: wsRoot,
|
|
WorkspaceID: "ws-1",
|
|
TaskID: "11111111-2222-3333-4444-555555555555",
|
|
AgentName: "scout",
|
|
Provider: "openclaw",
|
|
OpenclawBin: stub.bin,
|
|
Task: TaskContextForEnv{
|
|
IssueID: "issue-1",
|
|
},
|
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
if env.OpenclawConfigPath == "" {
|
|
t.Fatal("Prepare(openclaw) did not set OpenclawConfigPath")
|
|
}
|
|
got := mustReadJSON(t, env.OpenclawConfigPath)
|
|
workspace := got["agents"].(map[string]any)["defaults"].(map[string]any)["workspace"]
|
|
if workspace != env.WorkDir {
|
|
t.Errorf("agents.defaults.workspace = %v, want %q", workspace, env.WorkDir)
|
|
}
|
|
// Fresh install path emits no $include, so the Environment should
|
|
// leave OpenclawIncludeRoot empty — the daemon must NOT spuriously
|
|
// grant include roots when no cross-dir hop is being made.
|
|
if env.OpenclawIncludeRoot != "" {
|
|
t.Errorf("OpenclawIncludeRoot = %q on fresh install, want empty", env.OpenclawIncludeRoot)
|
|
}
|
|
}
|
|
|
|
// TestPrepareEnvironmentOpenclawWiresIncludeRoot — when the user has an
|
|
// on-disk active config (the common non-fresh-install case), Prepare must
|
|
// surface the active config's dirname on the Environment so the daemon
|
|
// can export OPENCLAW_INCLUDE_ROOTS. Without this, the wrapper's
|
|
// $include into ~/.openclaw/openclaw.json is rejected at runtime.
|
|
func TestPrepareEnvironmentOpenclawWiresIncludeRoot(t *testing.T) {
|
|
wsRoot := t.TempDir()
|
|
|
|
userCfgDir := t.TempDir()
|
|
userCfgPath := filepath.Join(userCfgDir, "openclaw.json")
|
|
if err := os.WriteFile(userCfgPath, []byte(`{}`), 0o600); err != nil {
|
|
t.Fatalf("write user cfg: %v", err)
|
|
}
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {stdout: userCfgPath},
|
|
"config get agents.list --json": {stdout: "null"},
|
|
})
|
|
|
|
env, err := Prepare(PrepareParams{
|
|
WorkspacesRoot: wsRoot,
|
|
WorkspaceID: "ws-1",
|
|
TaskID: "33333333-2222-3333-4444-555555555555",
|
|
AgentName: "scout",
|
|
Provider: "openclaw",
|
|
OpenclawBin: stub.bin,
|
|
Task: TaskContextForEnv{IssueID: "issue-1"},
|
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
if env.OpenclawIncludeRoot != userCfgDir {
|
|
t.Errorf("OpenclawIncludeRoot = %q, want %q (dirname of active config so daemon can grant OPENCLAW_INCLUDE_ROOTS)", env.OpenclawIncludeRoot, userCfgDir)
|
|
}
|
|
}
|
|
|
|
// TestPrepareEnvironmentOpenclawFailsClosed — when the openclaw CLI errors
|
|
// during Prepare, the whole call must fail. Previously the preparer logged
|
|
// a warning and continued with no config; we have removed that path.
|
|
func TestPrepareEnvironmentOpenclawFailsClosed(t *testing.T) {
|
|
wsRoot := t.TempDir()
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {err: errors.New("openclaw config validation failed")},
|
|
})
|
|
|
|
_, err := Prepare(PrepareParams{
|
|
WorkspacesRoot: wsRoot,
|
|
WorkspaceID: "ws-1",
|
|
TaskID: "22222222-2222-3333-4444-555555555555",
|
|
AgentName: "scout",
|
|
Provider: "openclaw",
|
|
OpenclawBin: stub.bin,
|
|
Task: TaskContextForEnv{IssueID: "issue-1"},
|
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err == nil {
|
|
t.Fatal("Prepare(openclaw) succeeded when CLI errored; expected fail closed")
|
|
}
|
|
if !strings.Contains(err.Error(), "prepare openclaw config") {
|
|
t.Errorf("error message %q does not name the openclaw config step", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestPrepareEnvironmentNonOpenclawSkipsConfig — non-openclaw providers
|
|
// must not get a synthesized openclaw config (it would be dead weight on
|
|
// disk and confuse the GC reaper's idea of what an env contains). They
|
|
// also must NOT shell out to the openclaw CLI, so the stub here records
|
|
// zero calls.
|
|
func TestPrepareEnvironmentNonOpenclawSkipsConfig(t *testing.T) {
|
|
wsRoot := t.TempDir()
|
|
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{})
|
|
|
|
taskIDs := map[string]string{
|
|
"claude": "aaaaaaaa-1111-2222-3333-444444444444",
|
|
"opencode": "bbbbbbbb-1111-2222-3333-444444444444",
|
|
"hermes": "cccccccc-1111-2222-3333-444444444444",
|
|
"kiro": "dddddddd-1111-2222-3333-444444444444",
|
|
}
|
|
for provider, taskID := range taskIDs {
|
|
t.Run(provider, func(t *testing.T) {
|
|
env, err := Prepare(PrepareParams{
|
|
WorkspacesRoot: wsRoot,
|
|
WorkspaceID: "ws-1",
|
|
TaskID: taskID,
|
|
AgentName: "scout",
|
|
Provider: provider,
|
|
Task: TaskContextForEnv{IssueID: "issue-1"},
|
|
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatalf("Prepare(%s): %v", provider, err)
|
|
}
|
|
if env.OpenclawConfigPath != "" {
|
|
t.Errorf("provider %s should not get an OpenclawConfigPath, got %q", provider, env.OpenclawConfigPath)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(env.RootDir, openclawConfigFile)); !os.IsNotExist(err) {
|
|
t.Errorf("provider %s left a stray openclaw-config.json", provider)
|
|
}
|
|
})
|
|
}
|
|
if len(stub.calls) != 0 {
|
|
t.Errorf("non-openclaw providers shelled out to openclaw CLI %d times: %+v", len(stub.calls), stub.calls)
|
|
}
|
|
}
|