Commit Graph
5 Commits
Author SHA1 Message Date
d2423aa35c MUL-7590: fix(execenv): support OpenClaw agents.entries per-agent workspaces (#8678)
* 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]>
2026-09-22 16:22:28 +08:00
4dfa08917b fix(daemon): isolate managed OpenClaw MCP servers without reading user config (MUL-6684, MUL-6857) (#7279)
* fix(daemon): build the managed-MCP config from `config get mcp --json`

Preparing a task for an OpenClaw agent with a managed `mcp_config` reads the
user's resolved configuration so the wrapper can replace `mcp.servers` while
keeping the user's other `mcp.*` settings. That read asks for the resolved root,
`openclaw config get --json` with no key path, and current releases do not
answer it: measured against 2026.5.27 the CLI writes zero bytes to stdout and
never exits, and the same shape is reported against 2026.7.1-2. Every
managed-MCP task on such a host therefore fails preparation.

`config get mcp --json` is answered, and it is also the only part of the
configuration this code needs. Switching to it means the snapshot can no longer
be a flat copy of the user's resolved config, so it becomes an include chain:

  1. `$include` the user's live config,
  2. merge `{"mcp": null}`, which OpenClaw's include merge treats as a
     wholesale replacement of the user's MCP object,
  3. restore the resolved non-server MCP settings.

The wrapper then contributes the managed server set, which is the only server
definition the chain can yield. `sessionIdleTtlMs` and its siblings still
resolve; user-only servers do not.

Security posture moves in both directions, so both are worth stating:

- Better: the task directory no longer receives a copy of the user's resolved
  configuration. That copy was a flat snapshot of everything the loader
  produced, which the code it replaces acknowledged could carry API keys and
  model-provider tokens, mitigated only by 0o600. The chain writes include paths
  and MCP tuning, and nothing else.
- Worse: because the chain reaches out to the user's real config, the daemon has
  to grant its directory through OPENCLAW_INCLUDE_ROOTS. The flat copy was
  self-contained and needed no grant. The trade is a read grant on one directory
  instead of duplicating that directory's secrets into per-task state, which
  also means the include never goes stale relative to the user's config.

The MCP read uses the same deadline as every other call in the preparation
rather than a shorter budget of its own. It is one of the
openclawMaxCLICallsPerPreparation calls the budget arithmetic already accounts
for, it is the same class of invocation as the discovery calls — measured at
4.3s on the slow host in #7142 — and it has to honour
MULTICA_OPENCLAW_CLI_TIMEOUT for the same reason those do. A shorter private
budget would fail closed on exactly the machines that raise the override because
they need it.

`openclawResolvedFullConfig` and `stripUserMcpServers` lose their only caller.
The discovery cache's note about deliberately not caching the resolved config is
updated: the reason is no longer that the payload holds secrets, but that the
MCP subtree is per-agent state that has to reflect the user's configuration as
it is now.

Tests: the three strict-replace tests move to the MCP subtree and assert the
chain rather than a copy, including that the snapshot contains none of the
user's server bytes. TestPrepareOpenclawConfigIncludeRootGrants covers the grant
for all three include targets, because getting it wrong fails at OpenClaw's
include-confinement check with nothing pointing back here. The real-CLI test
behind the agentintegration tag asks the installed binary to resolve the
generated chain, since the correctness of the reset bridge lives in OpenClaw's
merge semantics rather than in the JSON this package writes.

* fix(ci): compile the agentintegration tests, and correct the snapshot doc

Two review follow-ups on the managed-MCP include chain.

The `openclawUserSnapshotFile` comment still described the flat sanitized
copy this PR replaced — "the user's config minus the `mcp` block". The file
now holds no user config bytes at all, only an include chain, which is also
why its 0o600 is about the paths it names rather than secrets it carries.
The name stays because the wrapper's include target is not worth churning
for a rename; the comment says why.

The design's load-bearing claim — that `{"mcp": null}` makes OpenClaw's
include merge replace the user's MCP object rather than merge into it —
is verified by openclaw_mcp_real_integration_test.go, and nothing compiled
that file. `agentintegration` guards nine files across the module and no CI
job referenced the tag, so a helper renamed in an ordinary test file, or two
branches each defining one in the same package, breaks the tagged build with
every untagged job still green. That is not hypothetical here: #6275 adds
openclaw_real_integration_test.go to this package with its own
`realOpenclawBin`, and dropping it in beside this one leaves `go build ./...`
passing while the new step reports

  vet: openclaw_real_integration_test.go:18:6: realOpenclawBin redeclared

vet type-checks test files, so the gate needs no agent CLI and no runner
that can execute one. It sits next to Build, before the suite that would
otherwise be the first thing to notice.

* docs(execenv): scope the root-read claim to what was actually measured

Two comments asserted that "current releases" do not answer
`config get --json`, citing a report against 2026.7.1-2 as corroboration. The
measurement behind them is one host on 2026.5.27, and the citation does not
support the claim: the 2026.7.1-2 reports (@kaverjody in #6275, #7308) are both
about `openclaw config file` — cold-start timings and a descendant holding
stdout — not about the root read. #7308 in fact measures `config file` exiting
cleanly in 4.2s on that version.

Which command is which is the whole premise of this change: `config get mcp
--json` is answered while the root is not, so treating an observation of one as
evidence about the other undercuts the argument it was meant to support.

Scoped both comments to the measurement, and stated that the root read's
behaviour on 2026.7.1-2 is unmeasured. The decision does not need the broader
claim: the subtree is the only part of the configuration this code needs, and
the fail-open branch being removed is a silent-degradation bug on its own terms,
reachable from any cause that makes the read come back empty.

* fix(daemon): harden OpenClaw MCP isolation

* fix(execenv): correct the root-read diagnosis against measured channels

Measured 2026-08-26 on Windows 10.0.19045.6466 against the three npm channels.
Two claims in this branch were wrong, and the corrected version is a stronger
argument for the change rather than a weaker one.

**The root read does not hang; it is not a valid invocation.** `config get`
requires a path, and the pathless form is rejected outright:

    2026.6.34   (extended-stable)  exit 1 in 2.9s  Missing required argument "path".
    2026.7.1-2  (latest)           exit 1 in 3.0s  same message
    2026.8.1-beta.3 (beta)         exit 1 in 4.4s  same message, JSON envelope

So `openclawResolvedFullConfig` did not degrade on some hosts — it failed
preparation for every managed-MCP task on all three channels shipping today.
2026.5.27 writing nothing and never exiting within 60s is one version differing
from every current one, and is now recorded as that rather than as the general
case. Switching to the subtree stops being a workaround for a hang and becomes
the request this code should always have made.

**The "exits 0 with empty stdout" shape is unobserved.** It needs exit 0; all
three channels exit 1. The fail-open branch removal still stands on its own —
it is a silent-degradation path and the new chain has no equivalent — but it is
no longer offered as the mechanism behind the reported `Unknown model`, which
remains a real symptom with an unestablished cause.

Also corrected from the same measurements:

- The missing-key transports are not a migration. 2026.6.34 and 2026.7.1-2 are
  stderr-only with empty stdout; only 2026.7.2-beta.7 onward puts the envelope
  on stdout. Both paths are load-bearing for channels shipping today, which the
  detector doc now states with the version table.
- `config get agents.list --json` on 2026.6.34 and 2026.7.1-2 emits
  "Config path not found: agents.list" and names the path, so requiring the
  diagnostic to name the requested key does not regress the registry fallback on
  current stable. `agents list --json` answers on all three.
- 2026.8.1-beta.3's `mcp` schema is exactly `servers` + `apps` with
  additionalProperties:false; `sessionIdleTtlMs` is gone. Stage 3 of the chain
  can only carry keys the same CLI just returned, which is what keeps it safe
  against that constraint. Comments now use `mcp.apps` as the current example.

New regressions, all from verbatim measured output:

- the pathless usage error must not read as a missing key, in both the stderr
  and the JSON-envelope form. This is the shape that actually occurs, and
  misreading it would turn a broken invocation into a silent "user has no mcp
  block";
- beta.3's invalid-config envelope (schema violation in a sibling `issues`
  array) must not read as a missing key either.

The smoke workflow now fails when the test skips. A skipped Go test reports `ok`
at the package level, so the job would have gone green whenever the CLI was
absent or the -run pattern stopped matching — green exactly when nothing was
verified. #6897's Windows step documents the same hazard.

The real-CLI test's doc records the platform, the three resolved versions and
their PASS durations (36.9s / 40.6s / 34.2s), and the hand-run control: dropping
the reset stage leaks `user-only` back into the resolved server map on all three
channels, which is what makes those passes mean something.

* test(execenv): pin the reset-stage/wrapper-mcp pairing the loader depends on

The reset stage is only safe because the wrapper always contributes its
own mcp object under the same condition: OpenClaw merges sibling keys
over the include result, and a null that instead survives to the
resolved root fails schema validation (zod .strict().optional() rejects
null), taking the whole config load down. The pairing held by
construction but nothing named it; this assertion fails in the PR that
breaks it, instead of a week later in the scheduled smoke.

Co-Authored-By: Claude Fable 5 <[email protected]>

* docs(execenv): match the deadline-budget arithmetic to the constant

openclawMaxCLIDeadlinesPerPreparation dropped from 4 to 3 when the
managed-MCP read step was removed, but the worst-case explanation on
openclawCLIMaxTimeout still said 4 x 60s = 4m. Say 3 x 60s = 3m so the
documented safety arithmetic matches the constant and its test.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: weiweiwei <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-09-03 12:33:39 +08:00
VvVandweiweiwei 7d128b5a45 fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467, MUL-5630, MUL-6736) (#6275)
* fix(daemon): own the openclaw CLI process tree so its deadline is enforceable (MUL-5467)

openclawCLITimeout has carried a "Known gap (deliberately not fixed here)"
note since #6084: the 5s deadline could not actually bound an
`openclaw config ...` invocation. This closes it, and fixes a second
OpenClaw misbehaviour found alongside it.

## Bug 1 — the deadline was decorative

exec.CommandContext kills only the direct child. cmd.Output() blocks in
Wait() until the output pipes os/exec manages reach EOF, and cancelling the
context does not unblock that io.Copy. So when openclaw leaves a descendant
holding stdout — its `openclaw-config` helper, or cmd.exe → node on Windows
— the call runs for the *descendant's* lifetime.

#6084 measured this on linux/dash (a shim whose backgrounded child slept 6s
took 6.01s against a 150ms deadline), tried a cmd.WaitDelay backstop, and
reverted it on review because it bounds the call by leaving the descendant
running — trading a hang for a process leak that nothing on Unix reaps. The
note left behind names the real fix: process-tree ownership.

Three call sites had the shape:

  - execenv.execOpenclawCLI       on the task's critical path
                                  (Prepare → prepareOpenclawConfig)
  - agent.detectCLIVersion        runs for every provider inside the
                                  daemon's blocking registration preflight
  - agent.discoverOpenclawAgents  four invocations per call

## Bug 2 — the answer was already on stdout

Measured on an OpenClaw host (openclaw 2026.5.27):

  openclaw --version    258ms  exits cleanly
  openclaw config file    60s  correct path printed, then never exits
  openclaw agents list    60s  correct list printed, then never exits

Waiting for exit turns two working commands into a task-fatal error:

  agent_error.process_failure (prepare execution environment: execenv:
  prepare openclaw config: locate openclaw active config:
  openclaw config file: context deadline exceeded (process: signal: killed))

The contract of these commands is "print a value". Once the value has
arrived and nothing more is coming, whether the process tidies itself up is
not the caller's business, and it should not fail a chat task.

## The fix

Two helpers in server/pkg/agent, both built on the process-group primitives
codex/claude/opencode already use — no new platform-specific functions:

  - RunCollect: hands os/exec an *os.File instead of a buffer, so os/exec
    starts no copy goroutine and Wait returns the instant the direct child
    exits. Then signals the child's process group, which reaps the helper
    and releases the last write end, so our own readers see EOF. Unlike
    WaitDelay this reports the CLI's real exit status, which matters because
    a failed `--version` probe skips runtime registration entirely, and
    because openclawShimDiagnostic type-switches on *exec.ExitError.
  - RunCollectQuiet: returns when the process exits, or when stdout has
    produced output that then stayed idle for 400ms — whichever comes first.
    Reports quiet=true on the idle path so callers can log the CLI's failure
    to exit without failing on it.

The idle shortcut deliberately cannot mask a silent CLI: with no output
there is nothing to salvage, so the deadline still governs and the call
still fails. A non-zero exit still fails too, with stderr intact.

execOpenclawCLI keeps its shim diagnostics and its context-attribution
order byte-for-byte; only the exec mechanism changes.
discoverOpenclawAgents keeps its existing 30s function-level budget rather
than gaining a per-attempt one.

Windows: there is no process group to signal, so calls are bounded (by the
drain grace) but a descendant can still outlive one. Owning descendants
there needs a Job Object and is not attempted here.

## Verification

  - Both packages green with -race; 6 consecutive runs, no flakes.
  - All 51 existing Openclaw* tests in execenv pass, including
    TestExecOpenclawCLITimeoutIsNotMisdiagnosedAsMissingInterpreter and
    TestExecOpenclawCLICancellationIsWrapped — their shims produce no
    stdout, so the salvage path is not reachable and the
    errors.Is(err, context.DeadlineExceeded) contract is preserved.
  - 13 new tests. Both mechanisms are mutation-verified:
      * RunCollect reverted to buffers with no group reap → the guard test
        hangs and the dump is the production stack: cmd.Wait →
        awaitGoroutines blocked, writerDescriptor parked in io.Copy.
      * RunCollectQuiet's idle path disabled → both the unit test and the
        execenv end-to-end test fail after a full 60s.

* fix(daemon): address review on the openclaw CLI collector (MUL-5467)

Three blocking items from review of #6275. All three were reproduced before
being fixed, and the first one was a genuine correctness hole rather than a
style objection.

## 1. The deadline branch salvaged partial answers

RunCollectQuiet returned success from the ctx.Done() branch whenever stdout was
non-empty, without requiring the idle grace or any check that the answer was
finished. A CLI still streaming when the deadline arrived therefore had its
truncated output reported as a completed response. Reproduced with a stub
emitting a JSON document forever against a 250ms deadline: 9 runs in 10 came
back as success carrying an unparseable fragment.

The early return now needs two independent conditions, neither sufficient
alone:

  - an OutputComplete rule supplied by the caller accepts the buffer, and
  - the buffer has since been idle for idleGrace.

Reaching the deadline is never success. The captured bytes are still returned
alongside the error so a caller with its own rule can inspect them, but the
runner no longer decides on the caller's behalf. A nil rule disables the early
return entirely, which is the conservative default for a command whose output
shape has no completeness rule.

The rule cannot be inferred inside the runner, which is the point: only the
caller knows whether `{"agents":[{"id":"a"},` is truncated or legitimate.

  - `--json` subcommands -> agent.JSONOutputComplete (whole buffer parses).
  - `openclaw config file` -> openclawConfigPathComplete (last non-empty line
    looks like a path). Deliberately stricter than
    openclawParseActiveConfigPath, which resolves a relative line through
    filepath.Abs and would therefore accept a Doctor warning border as an
    answer. That matters because OpenClaw prints Doctor UI *before* the path
    (MUL-3136), so an early return on idle output alone hands back the banner
    as the config path — and nothing downstream catches it.
  - `agents list` text fallback -> RunCollect (wait for exit). Decorated text
    has no completeness rule and a silently short agent catalog is worse than a
    slow one; this path still gains the reaping and the bounded return.

## 2. quietWriter published bytes before their timestamp

The buffer write and the last-write timestamp were updated in separate critical
sections, so a reader could observe new bytes carrying a stale timestamp and
conclude the stream had gone quiet at the moment it was producing — truncating
an answer mid-write. Both now move in one critical section (outputBuffer).

## 3. The drain-timeout path raced the readers, and could park a goroutine

RunCollect read its buffers after the drain grace expired while io.Copy could
still be appending to them; the deferred Close on the read ends ran only after
the return values had been evaluated. Separately, RunCollectQuiet handed os/exec
io.Writers, so its cmd.Wait goroutine could sit forever on a descendant holding
the pipe — on Windows nothing released it, since there is no process group to
signal.

Both are now structural rather than patched. A single collector owns the pipes
for both helpers (the *os.File ownership RunCollect already had), and finish()
guarantees that when it returns, every goroutine startCollector spawned has been
joined and nothing can still append to the buffers: it reaps the tree, waits
bounded for Wait, and if the readers still have not seen EOF it closes the read
ends outright — which makes the in-flight Read return — and only then joins them.

## Verification

  - Both packages pass with -race under CI's flags (-p 2 -parallel 2).
  - All pre-existing Openclaw* tests still pass, including the timeout
    attribution and shim diagnostic ones.
  - New regression tests, all three named in the review: continuous output until
    the deadline (must not be success), a prompt followed by a delayed body (must
    not return the prompt), and a late non-zero exit within the grace (must be
    reported as the failure it is). Plus the completeness rules per subcommand,
    the banner-then-path case end to end through execOpenclawCLI, goroutine
    collection on both helpers, and the two outputBuffer contracts.
  - Windows contract coverage added (run_collect_windows_test.go) with a scoped
    step in the existing windows-execenv CI job, since the close-the-read-ends
    path is unreachable on Unix where the group kill releases the pipe.
  - Mutation-verified: restoring the unconditional salvage fails both the unit
    and the execenv regression; dropping the completeness rule from
    execOpenclawCLI makes the Doctor banner come back as the config path.

* test(daemon): make the openclaw config-path fixtures hermetic

The two execenv fixtures printed the literal path the host had printed,
`/root/.openclaw/openclaw.json`. openclawParseActiveConfigPath stats what it
parses and tolerates only os.ErrNotExist, so that path resolves differently by
environment: ENOENT on a developer machine, where /root does not exist, but
EACCES on a Linux CI runner, where it exists and is unreadable. The second case
is a hard error, so TestExecOpenclawCLIWaitsForThePathAfterDoctorBanner failed
in CI for a reason unrelated to the boundary it covers.

Both fixtures now print a real file inside t.TempDir(), which stats cleanly
everywhere. The second test only inspects raw stdout today, but a hard-coded
path there would be a trap for whoever next adds a parse to it.

The literal path stays in the completeness-rule table, which calls the
predicate directly and never stats, so it keeps documenting the shape the CLI
actually emits.

* fix(agent): own one-shot CLI process trees on Windows

* fix(daemon): support current OpenClaw config discovery

* Address review, and narrow this branch to the process-tree fix

Blocking items from the review:

1. The deadline branch no longer salvages. It returns an error unconditionally,
   and callers supply a per-subcommand completeness rule (openclawOutputComplete)
   that decides whether the captured bytes are a finished answer. Regression
   coverage for the three shapes named in the review: output continuing right up
   to the deadline, a banner ahead of the real answer, and a late non-zero exit.

   finish() now reports, rather than being wired to a field that was never
   assigned. Two cases do not converge and both cost the caller something it
   would otherwise never learn: a process still outstanding in Wait after the
   tree kill, and a descendant still holding the pipe when the read ends had to
   be force-closed, which can truncate trailing output. collectorError joins that
   with the command's own error, so nothing is displaced.

2. outputBuffer publishes bytes and the write timestamp inside one critical
   section, so a poller cannot see new bytes with a stale timestamp and conclude
   the stream went quiet while it was in fact producing.

3. The drain-timeout path closes the read ends and waits for both absorb loops
   before any snapshot is taken, so no io.Copy can be appending to a buffer the
   caller is about to read. Windows contract coverage runs as its own CI job.

   The goroutine guarantee is stated at its real strength instead of "no
   goroutine outlives the call, on any platform". Convergence holds whenever the
   tree kill takes. When it does not, one goroutine can stay parked in cmd.Wait
   on a process nothing can terminate; that residue is not removable here, so it
   is reported instead of asserted away. On Windows the same case extends the
   call rather than leaking, because an anonymous pipe is not pollable and
   closing the read end waits for an in-flight Read to release it.

Also from review:

- A JSON error envelope is valid JSON, so JSONOutputComplete accepts one and a
  CLI that prints `{"error": ...}` and then lingers past the idle grace hands
  back err == nil with the envelope in stdout — where the exit-status path never
  sees it. All three JSON readers now check for that explicitly. It matters most
  for `config get agents.list --json`, where the envelope is how a 2026.6+ host
  says the key moved to the registry: decoding it as data turned a graceful
  fallback into a failed preparation. For the object-target read the failure is
  silent, since the envelope would be sanitized and written into the task's
  snapshot as if it were the user's config.

- RunCollectCmd selects on ctx.Done(). It takes a command the caller built, so
  enforcement must not depend on that caller having used exec.CommandContext;
  the ordering matches RunCollectQuiet so the two entry points cannot report
  different error shapes for the same situation. startCollector drops its unused
  ctx parameter rather than implying it enforces one.

- checkOpenclawVersion keeps stderr in the parse. It replaced CombinedOutput(),
  and a build that prints its version there would otherwise fail the gate on
  empty output, which skips runtime registration entirely. extractVersionLine is
  bypassed on purpose: parseOpenclawVersion already scans the whole text, while
  picking the first line containing a semver lets unrelated stderr noise answer
  for it. The probe is bounded because the caller's ctx is the task's, and
  ExplainExecError is applied here too so an ENOEXEC shim is still named
  (MUL-6164).

- TestRunCollectQuietWithoutCompletenessRuleWaitsForExit synchronizes on a
  marker the CLI writes after its answer instead of a deadline. Its two
  assertions want opposite budgets, and the reviewer reproduced the resulting
  flake 3/3 under load. Waiting for the marker also proves more than a timeout
  could: that the runner was still blocked at a moment when the answer was
  already fully available.

Scope: the MCP work that shared this branch is reverted to main's behaviour and
will come back on its own, because it needs a discussion this one does not. It
changed which command supplies the resolved MCP settings, restructured the
include chain, and moved where the user's config bytes live — none of which the
deadline fix depends on. Preparation for a managed-MCP agent behaves exactly as
it does on main here.

$OPENCLAW_HOME path expansion stays: openclawConfigPathComplete calls
openclawHomeRest directly, so the completeness rule for `config file` cannot
tell whether that shape is a finished answer without it.

* fix(agent): converge the collector's tree kill and stop failing calls on cleanup (MUL-5467)

Review follow-up on #6275. All three findings share one root cause: whether the
caller got its answer depended on whether cleanup converged, and cleanup
convergence depended on a single kill(-pgid) landing.

- Retry the tree kill across collectReapWindow. One pass loses a descendant
  whose fork completes between the kill's enumeration of the process group and
  the signal's delivery — measured 3 misses in 10 runs of the forking stub, each
  leaving a `sleep 300` reparented to init and stalling the call until the drain
  budget expired. The window is deliberately short so it does not widen the
  pid-reuse window reapProcessTree already documents.

- Replace the 5s EOF budget with a 400ms settle. Once the direct child has
  exited, at most one pipe buffer of its output can still be unread — a child
  that had written more would have blocked in write() and could not have exited
  — so a short cap cannot truncate a well-behaved CLI's answer. What it cuts
  short is a surviving descendant's trailing output, which is not the answer.

- Stop reporting cleanup residue as a call error. finish() now logs and returns
  nothing: a probe whose output arrived and whose helper the kernel would not
  kill is exactly the case #6084's cmd.WaitDelay got wrong, and a failed
  version probe skips runtime registration entirely. This drops finishErr,
  collectorError and the drainForced/waitPending split, which the four call
  sites were interpreting two different ways by accident.

- Fix the reaping tests. They asserted on `$$` captured inside `( )`, which
  POSIX keeps at the invoking shell's pid, so they named the direct child that
  cmd.Wait reaps regardless — all of them passed with reapProcessTree removed,
  while the run leaked 7 orphans. Record `$!` instead, and add
  TestRunCollectRetriesTheTreeKill, which injects a missed pass because the race
  lives inside one syscall and cannot be forced from a stub.

Verified: collector group 0/10 failures (was 3/10) with 0 orphans, 0/5 under
-race, full ./pkg/agent -race green, execenv Openclaw -race green, and both
mutations — retry removed, reaping disabled — fail the tests that now cover
them, on their own assertions rather than on a cleanup error.

* refactor: move the `$OPENCLAW_HOME` expansion out of this branch

It is not process-tree work. `openclaw config file` printing
`$OPENCLAW_HOME\...` instead of the expanded path is the same class of bug as
#6630 — a path the daemon cannot stat, reported as a fresh install, wrapper
without the user's `$include` — and #6643 shipped the tilde half of it as its
own change. This half belongs there too, not folded into a branch about
owning the CLI's process tree.

Removed here: openclawHomeRest, its branch in expandOpenclawPath, the unit
test, and the completeness-rule row that depended on it. Neither PR now
depends on the other.

openclawConfigPathComplete therefore does not recognize the variable shape,
which is correct in isolation: `main` cannot expand it either, so a rule that
accepted the line would hand the caller a path the parser then fails on.
Such a host keeps `main`'s behaviour — no rule match means RunCollectQuiet
waits for exit — so it loses the hang tolerance and nothing else. The comment
on that function says so and names the change that should close it, because a
gap nobody wrote down is one a rebase re-opens silently.

* fix(daemon): teach the config-path rule the `$OPENCLAW_HOME` shape (#7310 hand-off)

#7310 landed the parser side of this, so the deferred half is now due — and it
was recorded in the code precisely so a rebase could not drop it silently.

While expandOpenclawPath could not resolve `$OPENCLAW_HOME\...`, this rule had
to refuse the shape: accepting it would have reported a finished answer and then
handed the caller a path the parser failed on. The cost was that a host with that
variable set lost the early return entirely and went back to waiting for the CLI
to exit — which is the hang this branch exists to survive. With the parser
fixed, the refusal was the only thing left standing between those hosts and the
tolerance.

Resolved through openclawHomeFromEnv rather than read raw from the environment.
The value may itself be a tilde path that upstream expands before printing, so
`filepath.IsAbs` on the raw string answers false for `OPENCLAW_HOME=~/svc` and
silently withholds the early return on exactly the configuration #7310's review
was about. Mutation-checked: the raw-env version fails the new
`tilde-valued OPENCLAW_HOME` row with `rule(...) = false, want true`.

TestOpenclawOutputCompleteRules becomes subtests so a row can carry the
environment it is about, and gains three: an absolute OPENCLAW_HOME, a
tilde-valued one, and an unset one — the last staying false, so a line with
nothing to resolve it against still waits for exit and lets the parser report
the real error instead of this rule guessing.

* docs(agent): site the collector against outputOwned, not cmd.Output()

#7531 moved every one-shot probe in this package onto launch.go's
runOwned / outputOwned / combinedOutputOwned, which makes three claims in
this branch stale rather than wrong-in-detail:

- RunCollect called itself "the safe replacement for cmd.Output()". There
  are no cmd.Output() call sites left in pkg/agent. The comparison a
  reader needs is against outputOwned, which owns the same process tree
  and differs only in who owns the pipes — so that is what the doc
  compares, including why the probeWaitDelay bound is the right default
  for the probes it serves and the wrong one here.
- RunCollect's path-taking form advertised itself as the migration entry
  point for "the remaining cmd.Output() call sites (models.go's catalog
  probes, codex.go, deveco_models.go, thinking.go)". #7531 migrated all
  four to the owned helpers. It stays as the form for a caller holding
  only a path, and for a probe that turns out to need pipe ownership.
- collector's doc now says outright which of the two properties launch.go
  cannot provide, since "os/exec owns the draining" now describes
  in-package code rather than a stdlib call nobody here makes.

Also adds the `if: needs.changes.outputs.backend == 'true'` guard the new
Windows CI step was missing. Every other step in that job has it; without
it the step ran on frontend-only changes.

* docs(agent): cite upstream for the hang tolerance, not just the timings

The measured "prints its answer and then never exits" for `config file` and
`agents list` came from one host on openclaw 2026.5.27, which invites the
obvious objection: this is an upstream bug, so why compensate for it here
rather than wait for a fix.

Read at openclaw v2026.7.1, it is not a bug in a build — it is the exit
model. `src/cli/one-shot-exit.ts` provides an explicit opt-in,
`requestExitAfterOneShotOutput`, which `runCli` flushes after both streams
drain. In the shipped 2026.7.1 tarball exactly three modules take it:
`models list`, `models status`, and the hooks CLI. The commands this
package invokes — `config file`, `config get`, `agents list` — do not:
their success paths only print, and `runtime.exit` appears solely on their
failure paths, so termination is whatever the event loop happens to do.
A version that exits promptly is not promising the next one will.

Also pins openclawHomeRest's citation forward. `runConfigFile` still
prints shortenHomePath at v2026.7.1, and resolveHomeDisplayPrefix there
still selects `$OPENCLAW_HOME` on a non-empty trimmed variable and `~`
otherwise. Worth recording that the separator comes from the original path
rather than being inserted by the prefix, since that is what the rest-slice
in this function relies on.

* fix(daemon): size the config-file idle grace to the real warning gap

E2E on a real OpenClaw host (2026.7.1-2, plus 2026.6.8 and 2026.7.2-beta.7)
falsified two claims this branch was making. Both are corrected here; one
needed a code change.

**The 400ms idle grace was sized against the wrong gap.** The claim was
that a one-shot response arrives in back-to-back writes, so 400ms is
"orders of magnitude longer than the gap between those writes". The
first half is confirmed — 0.012-0.033ms between writes. The gap that
matters is a different one: with a config that produces Doctor and plugin
warnings, `openclaw config file` spread its stdout over 3289ms, of which a
single 3285ms silence sat between the end of the warning block and the
path. The default grace is eligible to fire inside that silence, on
whatever the warnings left in the buffer.

Why it lands on `config file` alone: upstream routes incidental console
output to stderr when argv carries `--json`
(withConsoleLogsRoutedToStderrForJson, src/cli/json-output-mode.ts at
v2026.7.1), so every `--json` caller here gets a stdout that is the JSON
document and nothing else, and a partial document cannot parse however long
the writer pauses. `config file` has no `--json`, so the warnings share its
stdout with the answer.

openclawConfigPathComplete is still the guarantee — a box-drawing border is
not an absolute path — and a grace can only be defence in depth, since the
gap is a property of the host's plugin and Doctor work. But it should not be
defence that loses by three seconds, so openclawQuietIdleGrace gives
`config file` 5s and leaves `--json` on the default. The cost is paid only
by a CLI that hangs: `config file` then returns in path + 5s rather than
path + 400ms, against a 30s deadline.

**RunCollect guarantee 2 overstated what a group kill can reach.** The
helper was measured holding the same stdout pipe as its parent (both
`pipe:[135202]`) while running with its own PGID and SID. `kill(-pgid)`
never reaches that, and neither does #7531's ownership, which uses the same
group. "Invoking a CLI on a timer cannot accumulate orphans" was therefore
wrong on Unix, and the three comments that repeated it are corrected. What
holds regardless is guarantee 1: it is pipe ownership, not the kill, that
makes Wait return and the deadline enforceable. Windows is unaffected — a
Job Object owns the tree irrespective of process groups.

Tests: TestOpenclawQuietIdleGraceCoversTheWarningGap pins the per-command
grace and asserts the `config file` value exceeds the measured gap;
TestExecOpenclawCLISurvivesTheWarningGapBeforeThePath drives a stub with a
warning block, a gap past the default grace, then the path, and requires
the path back.

* fix(agent): make pipe EOF the completion signal, and bound what is kept

Second review round found three blockers on b9b39eaea. All three reproduce
exactly as reported, and all three are cases where this branch was *worse*
than the launch.go helpers it replaced — which is the bar a replacement has
to clear, so the fix narrows the branch rather than patching around them.

**1. Leader exit was treated as answer completion (worst of the three).**
RunCollect*Cmd reaped the process group as soon as the direct child exited.
A wrapper can exit 0 while the real CLI — its descendant, holding the
inherited stdout — has not printed yet, which is the normal shape for an npm
or PowerShell shim. Measured here: the wrapper exits 0, its child prints the
version 500ms later, and this head returned an empty version with a *nil
error* in 427ms where outputOwned returns the version in 517ms. Empty-and-nil
is the worst available shape, and detectCLIVersion routes all 23 providers
through it.

Pipe EOF is the only signal that means "no more output is coming", so:

- detectCLIVersion, checkOpenclawVersion and discoverOpenclawAgents go back
  to launch.go's outputOwned / combinedOutputOwned, i.e. back to os/exec's
  own EOF wait. What this branch still fixes there is the *reporting*: a
  lingering descendant makes Wait report exec.ErrWaitDelay over an answer
  that arrived, and a failed --version probe skips runtime registration
  entirely (#6084 reverted a WaitDelay backstop over exactly that). So
  salvageProbeAnswer drops the error when, and only when, the caller's own
  parse found the answer — never on a non-zero exit, never on a deadline.
- The collector keeps its reason to exist (execenv's task-critical path) and
  gains awaitOutputAfterExit: after the direct child exits it waits for pipe
  EOF, bounded by collectDrainGrace (2s, matching probeWaitDelay), unless the
  caller's completeness rule says the answer is already in — so a CLI that
  prints and exits with a helper on the pipe still pays nothing.
- RunCollect / RunCollectCmd are deleted. With the probes back on
  outputOwned they had no production caller, and an exported wait-for-exit
  collector is exactly the API that invited this bug.

**2. A path-shaped warning could be returned as the config path.**
openclawConfigPathComplete accepted any absolute-looking last line, and the
previous commit's answer — a 5s grace — was the wrong dimension, as the
review pointed out by quoting this branch's own comment that no fixed grace
can bound the host's plugin work. Reproduced: a CLI printing an existing
plugin-cache.json path, pausing 6s, then printing the real path returned the
warning path after 5.51s.

Fixed by not asking a question content cannot answer. `openclaw config
validate --json` reports the path in a named `path` field, on every branch
(valid / invalid / file-not-found / error), in every version from v2026.5.5
— which is minOpenclawVersion — through v2026.8.1-beta.3, and `--json` makes
upstream route incidental output to stderr so stdout is the document alone.
openclawActiveConfigPath asks that first and keeps `config file` as a
fallback with no completeness rule at all: it waits for exit and fails closed
on the deadline. Upstream runs the same readConfigFileSnapshot() for both
commands, so preferring the JSON form costs no extra work; it costs one extra
invocation only on a CLI too old for either, which is pinned in
TestPrepareOpenclawConfigFallsBackWhenConfigFileUnsupported.

openclawConfigPathComplete, openclawQuietIdleGrace and
openclawConfigFileIdleGrace are gone. `--json` is now the only shape with a
rule, and the doc says why that is the design and not a gap.

**3. Retention was unbounded.** absorb appended without limit, where
outputOwned keeps the last probeStderrSampleBytes. Measured: 13,107,400 bytes
of stderr retained from a CLI writing continuously, ~400x the bound it
replaced. stderr now keeps its tail at collectStderrTail (= 32 KiB, so moving
a call site between mechanisms cannot change what a failed probe reports) and
stdout is capped at collectStdoutLimit and reports overflow as an error —
deliberately asymmetric, because a head-truncated answer is how a partial
catalog becomes a confident empty one.

Every new guard was mutation-verified by restoring the mechanism it covers:

| Mutation | Test | Failure |
|---|---|---|
| collector + reap on leader exit | TestDetectCLIVersionWaitsForAWrapperDescendant | `version = ""` in 0.42s |
| awaitOutputAfterExit returns immediately | TestRunCollectQuietWaitsForAWrapperDescendant | `stdout = ""` |
| drop the rule short-circuit | TestRunCollectQuietDoesNotWaitWhenTheAnswerIsIn | pays the full 2s |
| unbounded buffers | TestCollectedStderrKeepsOnlyItsTail | retained 13,107,417 bytes |
| unbounded buffers | TestCollectedStdoutOverflowIsReportedNotTruncated | 524,296 bytes, nil error |
| shape rule for `config file` | TestOpenclawActiveConfigPathIgnoresAPathShapedWarning | returns plugin-cache.json |

The new fallback test also caught a bug in this commit before it shipped:
openclawValidatedConfigPath accepted upstream's `CONFIG_PATH ?? "openclaw.json"`
literal, because expandOpenclawPath turned that relative name into a confident
path under the daemon's cwd. It now requires the reported value to be absolute
before expansion, allowing only the `~` and `$OPENCLAW_HOME` forms.

* fix(agent): repair the Windows-only collector test call sites

The mechanical conversion of the deleted RunCollect call sites over-widened
three tuples in run_collect_windows_test.go, which already used
RunCollectQuiet's four results. Host `go build` and `go vet` never compiled
them — the file is `//go:build windows` — so the break surfaced on the
windows-execenv job as `assignment mismatch: 5 variables but RunCollectQuiet
returns 4 values`.

Verified this time the way it should have been the first time:
`GOOS=windows go build ./...` and `GOOS=windows go vet ./...`, plus darwin and
linux. That is the check to run whenever this package's exported signatures
move, since a third of its process-lifecycle tests only compile on Windows.

* test(execenv): pin the config-path payload shapes E2E measured

E2E on OpenClaw 2026.7.1-2 confirmed `config validate --json` as the config-path
source and corrected two things this branch had inferred from source.

**The exit status is load-bearing in the wrong direction, and this was untested.**
Two of the three real states exit *non-zero* while carrying the answer: a missing
config file gives `{"valid":false,"path":"…","error":"file not found"}` and an
invalid one `{"valid":false,"path":"…","issues":[…]}`, both exit 1 with stderr
empty. A fresh install is the missing-file case, so reading a non-zero exit as
"no answer" would break first run. openclawValidatedConfigPath already ignored
the status, but nothing pinned it — writeOpenclawConfigStub could only exit 0, so
the tests covered the rarest branch. It now takes an exit code, and
TestOpenclawActiveConfigPathReadsThePathFromANonZeroExit drives all three
measured payloads.

**Two source-based claims corrected.**

- "Preferring the JSON form costs nothing extra" understated it. Measured over
  three runs each on the same host and config: 1.65/1.66/1.65s for
  `config validate --json` against 6.36/4.16/4.06s for `config file`. The JSON
  form is ~2.5-4x faster, which tracks the output it does not render — 715 bytes
  of JSON against 2663 bytes and 28 lines of warning UI.
- The reason a `--json` stdout is clean was attributed to upstream's
  console-log reroute (`withConsoleLogsRoutedToStderrForJson`). That function
  exists, but it is not what was observed doing the work: the Doctor and plugin
  warnings arrive as a `warnings` array *inside* the payload. The distinction
  matters for why JSONOutputComplete is a real signal — the stream is
  structurally one document, not merely quieter — so the comments now describe
  the measurement rather than the mechanism inferred from it.

Unrelated and pre-existing: `./pkg/agent`'s ACP timing tests (1-6s wall-clock
budgets) flake under load, and which ones fail varies per run. Control run of
`origin/main`'s whole package on this machine, same conditions: fails
TestCodexExecuteSemanticInactivityAllowsContinuousMessages and
TestCodexExecuteLegacyFirstTurnMessageSatisfiesProgress. Not introduced here.

* fix(agent): require a recognised version before salvaging a probe answer

The ErrWaitDelay salvage in detectCLIVersion was gated on `version != ""`,
and extractVersionLine falls back to the trimmed raw output when no line
carries a semver token — so any non-empty text satisfied it, including
whatever a wrapper printed before the real version existed.

Review reproduced it with a stub that prints a banner, exits 0, and has a
descendant print the version past the 2s WaitDelay: detectCLIVersion
returned "initializing plugins" with a nil error, and logged "CLI answered
but left its output pipes open" — the opposite of what happened. That value
is then persisted as the runtime version, for every provider, since
DetectVersion routes all 23 through here.

extractVersionLine now reports whether the semver scan actually matched, and
only that answers the salvage question. The trimmed-raw fallback still
stands on the success path: a CLI that exits 0 with an unusual version
format is reporting its version (#2516), and it is only unusable as evidence
that a bounded, incomplete read already holds the answer.

Mutation-checked: with the old gate,
TestDetectCLIVersionDoesNotSalvageABannerAsTheVersion fails with exactly the
reviewed shape — version="initializing plugins", nil error, misleading warn.

* test(agent): derive the collected-stdout cap from the largest answer

collectStdoutLimit argued for why a fail-closed cap is needed but not for
its value, and where it falls is a product compatibility boundary rather
than only a memory bound: a legal config past the cap stops a task from
starting at all.

The derivation is now recorded and executable. Measured answers on 2026.7.1-2
are 715 B (`config validate --json`) and 2663 B (`config file`); the largest
answer any call site asks for is the resolved config from `config get --json`,
which upstream puts no ceiling on. So the worst case is constructed — 1000
agents and 250 MCP servers with the fields OpenClaw carries, 547,291 bytes,
already orders of magnitude past a real deployment — and 8 MiB leaves 15x on
top of it while matching what the daemon already holds for a comparable
per-task payload (maxLocalSkillBundleSize).

Two tests pin it: the constructed high-water mark must round-trip through the
collector byte-identically with the stated headroom, and the boundary itself
is exact — at the limit the answer is returned whole, one byte past it fails
closed with errCollectStdoutTooLarge.

* fix(execenv): share one CLI deadline across both path-resolution attempts

openclawActiveConfigPath asks `config validate --json` first and falls back
to `config file`, and the fallback carried a fresh full deadline. That made
the worst case five budgets against a ceiling derived from four: at the 60s
override ceiling, 5m of CLI time alone, landing exactly on
daemon.defaultTaskPrepareTimeout — so the specific, non-retryable
ErrOpenclawCLITimeout collapses back into the generic retryable prepare
timeout, which is the outcome openclawCLIMaxTimeout exists to prevent.

Both invocations answer the same question, so they now share one context.
The multiplier is renamed to openclawMaxCLIDeadlinesPerPreparation because
that is what it always measured — the budget is per step, not per invocation.

The consequence is deliberate and asserted: a validate that burns the whole
budget leaves the fallback none, os/exec reports the expired context on Start,
and the failure still carries ErrOpenclawCLITimeout. A CLI that cannot say
where its config is within the entire deadline will not answer the same
question on a fresh one.

TestPrepareOpenclawConfigWorstCaseCLIBudgets now drives the real worst case
(validate fails, config file answers, registry fallback, managed MCP) and
counts distinct ctx deadlines rather than calls — the old test synthesized a
validate success from the `config file` response, so the two never fired in
the same run. Mutation-checked: with separate deadlines it reports 5 budgets
against a constant of 4.

---------

Co-authored-by: weiweiwei <[email protected]>
2026-08-28 14:04:10 +08:00
728ba9c58e fix(daemon): stop concurrent tasks evicting each other's OpenClaw discovery cache (#7204)
loadOpenclawDiscoveryCache rejected any entry stamped later than the reader's
clock sample, treating it as a clock jump. Callers sample time.Now() once and
pass it down, so on a daemon starting several tasks at once a peer routinely
commits a valid entry dated microseconds after the reader sampled — and the
reader, whose own store just succeeded, threw it away and re-ran discovery for
nothing. TestOpenclawDiscoveryCacheConcurrentPreparations flakes on CI for
exactly this reason; it reproduces locally in a few hundred runs.

Tolerate a bounded skew (1s) instead of any future dating at all. That is far
above the interleaving window and far below the clock jump the guard exists to
catch, and it bounds the cost of being wrong: an entry dated ahead outlives its
TTL by at most the tolerance.

TestOpenclawDiscoveryCacheFutureDatedEntry pins both edges — a concurrent
writer's entry is a hit, a jumped clock is still a miss — so the tolerance
cannot be widened into 'never expires' or narrowed back into the flake.

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 14:59:40 +08:00
Bohan Jiang 4d5a679b9b fix(openclaw): unblock the OpenClaw runtime on slow hosts (MUL-6321) (#7142)
Fixes #7112.

Task preparation ran `openclaw config file` and `config get agents.list --json` with a hardcoded 5s deadline; on a 2020 Intel MacBook Pro those take 8.4-10.9s and 4.3s every time, so the OpenClaw runtime was unusable there with no way to raise the limit.

- Deadline 5s -> 30s, overridable via MULTICA_OPENCLAW_CLI_TIMEOUT / backends.openclaw.cli_timeout, clamped to [1s, 60s] so the worst case (4 serial calls) stays inside the 5-minute preparation budget.
- Config discovery cached per daemon profile (on disk, since preparation runs in a one-shot helper process), invalidated by binary identity, OpenClaw path env, active config stat, and a 60s TTL. Only complete successes are cached; any cache fault degrades to a live CLI run.
- The timeout is classified structurally into a new platform-side, non-retryable reason (runtime_cli_timeout) instead of being text-matched into agent_error.provider_network, which showed "check your network" copy and auto-retried a deterministic local failure. Copy added for web/desktop/mobile; a NormalizeDaemonReason shim upgrades reports from older daemons.
2026-08-18 18:33:23 +08:00