mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* fix(execenv): pin per-agent workspaces under the OpenClaw agents.entries schema OpenClaw 2026.8 retired the agents.list config path and reports it as "Unknown config path: agents.list", moving the agents to a map keyed by agent id under agents.entries. The per-task wrapper only ever wrote agents.defaults.workspace, and an agents.entries.<id>.workspace entry outranks it verbatim: on a host where adding a second agent pinned the gateway agent to an absolute workspace, every task ran in that pinned directory instead of its prepared workdir, so the workdir skills/ never loaded and the agent edited the wrong tree. Read agents.entries when agents.list is gone, pin every entry workspace to the task workdir on write-back, and keep the registry fallback for the generations that have neither config path. The stdout-envelope branch now matches the path-aware wording, so a 2026.8 host that prints its envelope without exiting is read instead of failed closed, and the entries probe shares the agents.list deadline so the worst case stays three CLI deadlines. Co-authored-by: multica-agent <[email protected]> * test(execenv): cover the agents.entries probe, write-back and shared deadline Adds the regressions the review of the earlier attempt asked for: the current "Unknown config path" envelope with err == nil selects the entries probe and never the registry, both the plain and the managed-MCP flows pin every agents.entries.<id>.workspace to the task workdir, a valid-but-unset entries path falls back to the registry, and an envelope naming an unrelated path still fails closed. The worst-case budget test now drives the five-invocation chain and pins the config-schema pair to one shared deadline, so a fourth budget fails loudly. Co-authored-by: multica-agent <[email protected]> * fix(execenv): pin agents.entries workspaces without copying the entry Review feedback on the write-back: `config get` redacts the config before it returns the path it was asked for, so every sensitive per-agent field comes back as `__OPENCLAW_REDACTED__`. The wrapper is a sibling of the user's config in the $include merge and siblings win, so copying the resolved entry would write that sentinel over the user's real secret — memory search API key, web fetch headers, sandbox SSH credentials — for every task on the host, and nothing on OpenClaw's load path restores it. `agents.entries` is an object keyed by id and objects merge recursively, so `{"<id>": {"workspace": "<workdir>"}}` is enough to pin the workspace while every other field still arrives from the user's own config. The same reasoning drops the resolved payload from the row representation: an entries row now carries the id marker alone, so no redacted copy of the user's agent config is held in memory or written to the discovery cache file. Since only the source still decides the write-back, it is now carried explicitly (`openclawAgentsSource`: list / entries / registry) rather than as a boolean that effectively meant "not agents.list", and the source is recorded in the cache entry so a hit rebuilds the same wrapper. The cache shape version is bumped, so an entry written by an older daemon is a clean miss. Co-authored-by: multica-agent <[email protected]> * test(execenv): assert the entries write-back emits workspace alone Review feedback: the tests encoded the old contract (name / model carried into the wrapper verbatim). They now assert the wrapper carries exactly one key per entry — `workspace` — and that nothing else from the resolved payload reaches the file OpenClaw reads. The new regression drives the redaction shapes upstream's own redact-snapshot suite covers (memory search apiKey, web fetch headers, sandbox SSH credentials) through the entries probe of a real preparation, and fails if any of them ends up in the wrapper: with the copying write-back restored it reports the sentinel in `agents.entries.main`. The unit test for the write-back gate also covers a half-marked list, which a quiet per-row filter would have pinned anyway. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: tianshuai1 <[email protected]> Co-authored-by: multica-agent <[email protected]>
312 lines
14 KiB
Go
312 lines
14 KiB
Go
package execenv
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// slowestMeasuredOpenclawCall is the worst `openclaw config file` timing
|
|
// reported from the field (#7112): 10.86s on a 2020 Intel MacBook Pro, with
|
|
// 8.41s and 10.35s on the same host. It is a floor for what the default
|
|
// deadline must tolerate, not a target.
|
|
const slowestMeasuredOpenclawCall = 10860 * time.Millisecond
|
|
|
|
func TestResolveOpenclawCLITimeout(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
explicit time.Duration
|
|
env string
|
|
want time.Duration
|
|
}{
|
|
{name: "unset uses the default", want: openclawCLITimeout},
|
|
{name: "explicit wins over env", explicit: 3 * time.Second, env: "90s", want: 3 * time.Second},
|
|
{name: "duration string", env: "45s", want: 45 * time.Second},
|
|
{name: "bare seconds", env: "45", want: 45 * time.Second},
|
|
{name: "minutes past the ceiling are clamped", env: "1m30s", want: openclawCLIMaxTimeout},
|
|
{name: "large bare seconds are clamped", env: "3600", want: openclawCLIMaxTimeout},
|
|
// Past time.Duration's range there is nothing to clamp: parsing fails
|
|
// and the default applies. Worth pinning because the old Atoi path
|
|
// silently produced a negative duration here instead of failing.
|
|
{name: "bare seconds beyond time.Duration fall back", env: "99999999999999999999", want: openclawCLITimeout},
|
|
// A typo must degrade to the default rather than disabling the
|
|
// deadline or failing task preparation outright.
|
|
{name: "unparseable falls back", env: "soon", want: openclawCLITimeout},
|
|
{name: "empty falls back", env: " ", want: openclawCLITimeout},
|
|
{name: "zero falls back", env: "0", want: openclawCLITimeout},
|
|
{name: "negative falls back", env: "-5s", want: openclawCLITimeout},
|
|
// Clamped, not honoured: too small breaks every host, too large eats
|
|
// the outer preparation budget.
|
|
{name: "below the floor is clamped", env: "10ms", want: openclawCLIMinTimeout},
|
|
{name: "above the ceiling is clamped", env: "30m", want: openclawCLIMaxTimeout},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Setenv(OpenclawCLITimeoutEnv, tc.env)
|
|
if got := resolveOpenclawCLITimeout(tc.explicit, nil); got != tc.want {
|
|
t.Errorf("resolveOpenclawCLITimeout(%v, env=%q) = %v, want %v", tc.explicit, tc.env, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestExecOpenclawCLITimeoutIsTypedSentinel is what lets the daemon classify a
|
|
// local CLI stall structurally. Before this, the only signal was the words
|
|
// "deadline exceeded" in the message, which the shared classifier maps to
|
|
// agent_error.provider_network — "check your network" copy plus an auto-retry,
|
|
// for a failure that is local and repeats identically.
|
|
//
|
|
// The pre-existing context contract must survive alongside it: callers that
|
|
// check errors.Is(err, context.DeadlineExceeded) keep working.
|
|
func TestExecOpenclawCLITimeoutIsTypedSentinel(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell shim shape is covered by the windows-tagged tests")
|
|
}
|
|
sleepBin, err := exec.LookPath("sleep")
|
|
if err != nil {
|
|
t.Skipf("no sleep binary available to build a slow shim: %v", err)
|
|
}
|
|
shim := writeShim(t, t.TempDir(), "#!/bin/sh\n"+sleepBin+" 1\n", "")
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
_, err = execOpenclawCLI(ctx, shim, "config", "file")
|
|
if err == nil {
|
|
t.Fatal("expected the timed-out invocation to fail")
|
|
}
|
|
if !errors.Is(err, ErrOpenclawCLITimeout) {
|
|
t.Errorf("errors.Is(err, ErrOpenclawCLITimeout) must hold\ngot: %s", err)
|
|
}
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Errorf("errors.Is(err, context.DeadlineExceeded) must keep holding\ngot: %s", err)
|
|
}
|
|
if !errors.Is(err, ErrOpenclawCLITimeout) || errors.Is(errors.New("openclaw config file: boom"), ErrOpenclawCLITimeout) {
|
|
t.Errorf("the sentinel must not match unrelated CLI failures")
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigPropagatesTimeoutSentinel proves the sentinel
|
|
// survives the wrapping prepareOpenclawConfig does on the way out, which is
|
|
// what the daemon actually classifies.
|
|
func TestPrepareOpenclawConfigPropagatesTimeoutSentinel(t *testing.T) {
|
|
stub := installOpenclawStub(t, map[string]openclawResponse{
|
|
"config file": {err: &openclawCLITimeoutError{
|
|
msg: "openclaw config file: context deadline exceeded (process: signal: killed)",
|
|
cause: context.DeadlineExceeded,
|
|
}},
|
|
})
|
|
|
|
envRoot := t.TempDir()
|
|
_, err := prepareOpenclawConfig(envRoot, envRoot, OpenclawConfigPrep{OpenclawBin: stub.bin})
|
|
if err == nil {
|
|
t.Fatal("expected preparation to fail on a CLI timeout")
|
|
}
|
|
if !errors.Is(err, ErrOpenclawCLITimeout) {
|
|
t.Errorf("wrapped preparation error must keep the sentinel\ngot: %s", err)
|
|
}
|
|
}
|
|
|
|
// TestExecOpenclawCLICancellationIsNotATimeout keeps the sentinel honest. A
|
|
// daemon shutdown or a cancelled task also lands in the cancellation branch,
|
|
// but it is not a slow CLI — labelling it as one would tell the user to raise
|
|
// a deadline that was never involved.
|
|
func TestExecOpenclawCLICancellationIsNotATimeout(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell shim shape is covered by the windows-tagged tests")
|
|
}
|
|
sleepBin, err := exec.LookPath("sleep")
|
|
if err != nil {
|
|
t.Skipf("no sleep binary available to build a slow shim: %v", err)
|
|
}
|
|
shim := writeShim(t, t.TempDir(), "#!/bin/sh\n"+sleepBin+" 5\n", "")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
defer cancel()
|
|
_, err = execOpenclawCLI(ctx, shim, "config", "file")
|
|
if err == nil {
|
|
t.Fatal("expected the cancelled invocation to fail")
|
|
}
|
|
if errors.Is(err, ErrOpenclawCLITimeout) {
|
|
t.Errorf("cancellation must not be reported as a CLI timeout\ngot: %s", err)
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Errorf("cancellation must keep the wrapped context cause\ngot: %s", err)
|
|
}
|
|
}
|
|
|
|
// TestOpenclawActiveConfigPathSpendsOneBudgetAcrossBothAttempts is the
|
|
// consequence of sharing that deadline, asserted rather than left implicit.
|
|
//
|
|
// A `config validate --json` that burns the whole budget leaves the `config file`
|
|
// fallback none, and the call fails on the shared deadline instead of starting a
|
|
// second full one. That is the intended trade: a CLI that cannot say where its
|
|
// config is inside the entire deadline will not answer the same question on a
|
|
// fresh one, and the failure still carries ErrOpenclawCLITimeout — the specific,
|
|
// non-retryable reason — rather than doubling the time spent inside the outer
|
|
// preparation budget to reach an identical conclusion.
|
|
func TestOpenclawActiveConfigPathSpendsOneBudgetAcrossBothAttempts(t *testing.T) {
|
|
t.Parallel()
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("shell shim shape is covered by the windows-tagged tests")
|
|
}
|
|
sleepBin, err := exec.LookPath("sleep")
|
|
if err != nil {
|
|
t.Skipf("no sleep binary available to build a slow shim: %v", err)
|
|
}
|
|
// `config validate --json` hangs; `config file` would answer instantly if it
|
|
// were ever given the chance.
|
|
shim := writeShim(t, t.TempDir(),
|
|
"#!/bin/sh\n"+
|
|
"case \"$*\" in\n"+
|
|
" 'config validate --json') "+sleepBin+" 30 ;;\n"+
|
|
" 'config file') printf '/tmp/openclaw.json\\n' ;;\n"+
|
|
"esac\n", "")
|
|
|
|
const budget = 300 * time.Millisecond
|
|
start := time.Now()
|
|
_, _, err = openclawActiveConfigPath(shim, budget)
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("expected path resolution to fail once its shared deadline expired")
|
|
}
|
|
if !errors.Is(err, ErrOpenclawCLITimeout) {
|
|
t.Errorf("errors.Is(err, ErrOpenclawCLITimeout) must hold so the daemon still "+
|
|
"reports the specific, non-retryable reason\ngot: %s", err)
|
|
}
|
|
// The point of the assertion: two budgets would take at least 2x the deadline.
|
|
// The margin above one budget covers the collector's reap and settle windows.
|
|
if elapsed >= 2*budget {
|
|
t.Errorf("path resolution took %v, i.e. at least two %v budgets — the "+
|
|
"fallback must share the deadline, not start a fresh one", elapsed, budget)
|
|
}
|
|
}
|
|
|
|
// TestPrepareOpenclawConfigWorstCaseCLIBudgets pins the multiplier the budget
|
|
// above depends on, and it counts *deadlines* rather than calls.
|
|
//
|
|
// That distinction is the review finding it exists for. The worst case is not the
|
|
// common two calls: `config validate --json` can fail to answer and fall back to
|
|
// `config file`, and a 2026.6+ host falls back to the registry subcommand — four
|
|
// invocations. The earlier version of this test could not see the first pair,
|
|
// because the stub synthesizes a `config validate --json` success from the
|
|
// `config file` response, so the two never fired in the same run. With the
|
|
// fallback carrying a fresh full deadline, the worst case was five budgets at the
|
|
// 60s ceiling, i.e. 5m of CLI time alone, landing exactly on
|
|
// daemon.defaultTaskPrepareTimeout — and the specific, non-retryable
|
|
// ErrOpenclawCLITimeout collapses back into the generic retryable one.
|
|
//
|
|
// A managed mcp_config used to add a fifth invocation of its own. It no longer
|
|
// reads anything (see TestPrepareOpenclawConfigManagedMcpCostsNoExtraCLICall), so
|
|
// this drives the managed path too and still expects four invocations across
|
|
// three deadlines: path resolution's two share one.
|
|
//
|
|
// The config-schema read is itself a two-invocation chain now — 2026.8 hosts are
|
|
// asked for `agents.entries` when `agents.list` is gone — and those two share a
|
|
// budget for the same reason path resolution's pair does: they ask one question,
|
|
// and the second is only reached when the first says the path is not there. The
|
|
// worst case is therefore five invocations across three deadlines, and the
|
|
// registry fallback — a different question, answered from sqlite — still starts a
|
|
// deadline of its own.
|
|
func TestPrepareOpenclawConfigWorstCaseCLIBudgets(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{
|
|
// 1a. preferred path resolution, on a CLI too old to support it
|
|
"config validate --json": {err: errors.New("error: unknown command 'validate'")},
|
|
// 1b. fallback path resolution, under the same deadline as 1a
|
|
"config file": {stdout: userConfigPath},
|
|
// 2a. the schema generation whose per-agent list this host retired
|
|
"config get agents.list --json": {err: errors.New("Unknown config path: agents.list")},
|
|
// 2b. its keyed replacement, under the same deadline as 2a, on a host
|
|
// whose agents live in the registry
|
|
"config get agents.entries --json": {stdout: `{"error":"Config path is valid but unset: agents.entries"}`},
|
|
// 3. registry fallback
|
|
"agents list --json": {stdout: `[{"id":"scout"}]`},
|
|
// No fourth entry, and the managed mcp_config below is why that is worth
|
|
// stating: managed MCP is prepared from a file this package writes, so it
|
|
// adds no invocation. The stub errors on anything unregistered, so a
|
|
// reintroduced read fails here instead of quietly costing a budget.
|
|
})
|
|
|
|
if _, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{
|
|
OpenclawBin: stub.bin,
|
|
McpConfig: []byte(`{"mcpServers":{"fs":{"command":"fs-server"}}}`),
|
|
}); err != nil {
|
|
t.Fatalf("prepareOpenclawConfig: %v", err)
|
|
}
|
|
|
|
var invocations []string
|
|
deadlines := map[time.Time]bool{}
|
|
for _, call := range stub.calls {
|
|
invocation := strings.Join(call.args, " ")
|
|
invocations = append(invocations, invocation)
|
|
if call.deadline.IsZero() {
|
|
t.Errorf("`openclaw %s` ran without a deadline; every CLI step must be bounded", invocation)
|
|
continue
|
|
}
|
|
deadlines[call.deadline] = true
|
|
}
|
|
|
|
// The call graph itself, so a new invocation stays visible here even when it
|
|
// costs no extra budget.
|
|
wantInvocations := []string{
|
|
"config validate --json",
|
|
"config file",
|
|
"config get agents.list --json",
|
|
"config get agents.entries --json",
|
|
"agents list --json",
|
|
}
|
|
if strings.Join(invocations, " | ") != strings.Join(wantInvocations, " | ") {
|
|
t.Errorf("worst-case invocations:\n got: %v\nwant: %v", invocations, wantInvocations)
|
|
}
|
|
|
|
if got := len(deadlines); got != openclawMaxCLIDeadlinesPerPreparation {
|
|
t.Errorf("worst-case preparation consumed %d CLI deadlines (%d invocations), but openclawMaxCLIDeadlinesPerPreparation is %d\ncalls: %v",
|
|
got, len(stub.calls), openclawMaxCLIDeadlinesPerPreparation, invocations)
|
|
}
|
|
|
|
// And specifically that the shared budget is path resolution's, since that is
|
|
// the pairing the ceiling now depends on.
|
|
if len(stub.calls) >= 2 && stub.calls[0].deadline != stub.calls[1].deadline {
|
|
t.Errorf("`config validate --json` and its `config file` fallback ran under "+
|
|
"separate deadlines (%v vs %v); they answer the same question and must "+
|
|
"share one budget",
|
|
stub.calls[0].deadline, stub.calls[1].deadline)
|
|
}
|
|
// And that the config-schema pair shares its budget the same way, since a
|
|
// fresh deadline there is exactly what took the old ceiling to four.
|
|
if len(stub.calls) >= 4 {
|
|
if stub.calls[2].deadline != stub.calls[3].deadline {
|
|
t.Errorf("`config get agents.list --json` and `config get agents.entries --json` ran "+
|
|
"under separate deadlines (%v vs %v); they answer the same question and must "+
|
|
"share one budget, or the worst case grows a deadline",
|
|
stub.calls[2].deadline, stub.calls[3].deadline)
|
|
}
|
|
if stub.calls[4].deadline == stub.calls[2].deadline {
|
|
t.Errorf("the registry fallback shares the config-schema deadline; it reads a " +
|
|
"different source and must keep its own budget")
|
|
}
|
|
}
|
|
}
|