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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Rebased onto c25a82eee.

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

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

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

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

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

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

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

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

---------

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

467 lines
18 KiB
Go

package execenv
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// TestIsOpenclawShimPath locks the shim-detection surface. Case-insensitivity
// matters because Windows PATH resolution is case-insensitive and npm/PATHEXT
// can hand back `OPENCLAW.CMD`; paths containing spaces and non-ASCII segments
// are included because those are the Windows install locations most likely to
// be mis-parsed, and #6061's open questions called them out explicitly.
func TestIsOpenclawShimPath(t *testing.T) {
t.Parallel()
cases := []struct {
name string
bin string
want bool
}{
{"npm cmd shim", `C:\Users\dev\AppData\Roaming\npm\openclaw.cmd`, true},
{"uppercase extension", `C:\npm\OPENCLAW.CMD`, true},
{"mixed case extension", `C:\npm\openclaw.Cmd`, true},
{"legacy bat shim", `C:\npm\openclaw.bat`, true},
{"path with spaces", `C:\Program Files\node modules\openclaw.cmd`, true},
{"path with unicode segment", `C:\用户\开发\npm\openclaw.cmd`, true},
{"surrounding whitespace", " C:\\npm\\openclaw.cmd ", true},
{"real executable", `C:\npm\openclaw.exe`, false},
{"powershell shim is not a batch shim", `C:\npm\openclaw.ps1`, false},
{"unix binary without extension", "/usr/local/bin/openclaw", false},
{"unix path with dotted directory", "/opt/openclaw.cmd.d/openclaw", false},
{"empty", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isOpenclawShimPath(tc.bin); got != tc.want {
t.Fatalf("isOpenclawShimPath(%q) = %v, want %v", tc.bin, got, tc.want)
}
})
}
}
// exitError produces a real *exec.ExitError so the diagnostic's errors.As gate
// is exercised against the same type production sees, not a stand-in.
//
// The interpreter is invoked by absolute path on purpose: callers stub PATH to
// control the interpreter lookup, and a PATH-dependent helper would break
// depending on the order those two happen in.
func exitError(t *testing.T) error {
t.Helper()
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
shell := os.Getenv("ComSpec")
if shell == "" {
shell = filepath.Join(os.Getenv("SystemRoot"), "System32", "cmd.exe")
}
cmd = exec.Command(shell, "/c", "exit 1")
} else {
cmd = exec.Command("/bin/sh", "-c", "exit 1")
}
err := cmd.Run()
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *exec.ExitError, got %T (%v)", err, err)
}
return err
}
// pathWithout points PATH at an empty directory so the interpreter cannot
// resolve. Setting PATH rather than clearing it keeps LookPath on its normal
// code path instead of its empty-PATH special case.
func pathWithout(t *testing.T) {
t.Helper()
t.Setenv("PATH", t.TempDir())
}
// writeFakeInterpreter drops an executable named like the interpreter into dir.
// It only has to be resolvable — the diagnostic reports lookup results and
// never runs it.
func writeFakeInterpreter(t *testing.T, dir, name string) string {
t.Helper()
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write fake interpreter: %v", err)
}
return p
}
// pathWithFakeNode puts a resolvable interpreter on PATH and nowhere else.
func pathWithFakeNode(t *testing.T) {
t.Helper()
dir := t.TempDir()
name := openclawShimInterpreter
if runtime.GOOS == "windows" {
name += ".exe"
}
writeFakeInterpreter(t, dir, name)
t.Setenv("PATH", dir)
}
// TestOpenclawShimDiagnosticNamesUnreachableInterpreter is the core #6061
// regression: a silent shim exit must be reported as an unreachable
// interpreter, with an actionable next step, instead of a bare exit code.
func TestOpenclawShimDiagnosticNamesUnreachableInterpreter(t *testing.T) {
pathWithout(t)
shim := filepath.Join(t.TempDir(), "openclaw.cmd")
got := openclawShimDiagnostic(shim, exitError(t))
if got == "" {
t.Fatal("expected a diagnostic for a silent .cmd shim failure, got none")
}
for _, want := range []string{
"resolves neither alongside the shim nor on the daemon PATH",
openclawShimInterpreter,
"openclaw.cmd",
"install Node.js",
} {
if !strings.Contains(got, want) {
t.Errorf("diagnostic missing %q\ngot: %s", want, got)
}
}
}
// TestOpenclawShimDiagnosticFindsColocatedInterpreter is Sol-Boy's must-fix 2.
// npm's cmd-shim template checks `%dp0%\node.exe` BEFORE falling back to PATH:
//
// IF EXIST "%dp0%\node.exe" ( SET "_prog=%dp0%\node.exe" ) ELSE ( SET "_prog=node" )
//
// So an install whose Node sits next to the shim runs fine with nothing on
// PATH. Reporting "node is not resolvable" there would be confidently wrong,
// which is worse than staying quiet.
func TestOpenclawShimDiagnosticFindsColocatedInterpreter(t *testing.T) {
pathWithout(t) // nothing on PATH — only the co-located copy can be found
dir := t.TempDir()
shim := filepath.Join(dir, "openclaw.cmd")
name := openclawShimInterpreter
if runtime.GOOS == "windows" {
name += ".exe"
}
writeFakeInterpreter(t, dir, name)
got := openclawShimDiagnostic(shim, exitError(t))
if got == "" {
t.Fatal("expected a diagnostic, got none")
}
if !strings.Contains(got, "alongside the shim") {
t.Errorf("diagnostic should credit the co-located interpreter\ngot: %s", got)
}
if strings.Contains(got, "resolves neither") {
t.Errorf("diagnostic must not claim the interpreter is unreachable\ngot: %s", got)
}
}
// TestOpenclawShimDiagnosticReportsInterpreterOnPath guards the other
// direction, which is the evidence that actually discriminates between the
// competing #6061 hypotheses. If the interpreter resolves, the diagnostic must
// say so rather than blaming PATH, otherwise the next bug report gets steered
// toward the wrong root cause.
func TestOpenclawShimDiagnosticReportsInterpreterOnPath(t *testing.T) {
pathWithFakeNode(t)
shim := filepath.Join(t.TempDir(), "openclaw.cmd")
got := openclawShimDiagnostic(shim, exitError(t))
if got == "" {
t.Fatal("expected a diagnostic, got none")
}
if !strings.Contains(got, "on the daemon PATH") || !strings.Contains(got, "the interpreter is reachable") {
t.Errorf("diagnostic should clear PATH of blame\ngot: %s", got)
}
if strings.Contains(got, "resolves neither") {
t.Errorf("diagnostic must not claim the interpreter is unreachable\ngot: %s", got)
}
}
// TestOpenclawShimDiagnosticIsPhrasedConditionally is the rest of must-fix 2. A
// batch extension does not prove npm authorship — an operator can point
// MULTICA_OPENCLAW_PATH at any batch file — so the text must not assert npm
// shim semantics as established fact for whatever failed.
func TestOpenclawShimDiagnosticIsPhrasedConditionally(t *testing.T) {
pathWithout(t)
shim := filepath.Join(t.TempDir(), "custom-wrapper.cmd")
got := openclawShimDiagnostic(shim, exitError(t))
if got == "" {
t.Fatal("expected a diagnostic, got none")
}
if !strings.Contains(got, "if custom-wrapper.cmd is an npm-generated shim") {
t.Errorf("diagnostic should be conditional about npm authorship\ngot: %s", got)
}
}
// TestOpenclawShimDiagnosticRedactsLocalPaths is Sol-Boy's must-fix 3, and the
// reason it matters is the blast radius: on prep failure this text is not
// log-local. It travels reportTerminalTask → Client.FailTask and is persisted
// server-side as the task error, so an absolute Windows shim path would upload
// the account name and install layout.
func TestOpenclawShimDiagnosticRedactsLocalPaths(t *testing.T) {
secretDir := filepath.Join(t.TempDir(), "Users", "a-real-person", "AppData", "Roaming", "npm")
if err := os.MkdirAll(secretDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
shim := filepath.Join(secretDir, "openclaw.cmd")
pathDir := filepath.Join(t.TempDir(), "another-private-location")
if err := os.MkdirAll(pathDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
name := openclawShimInterpreter
if runtime.GOOS == "windows" {
name += ".exe"
}
interpreter := writeFakeInterpreter(t, pathDir, name)
t.Setenv("PATH", pathDir)
got := openclawShimDiagnostic(shim, exitError(t))
if got == "" {
t.Fatal("expected a diagnostic, got none")
}
for _, leak := range []string{secretDir, shim, pathDir, interpreter, "a-real-person", "another-private-location"} {
if strings.Contains(got, leak) {
t.Errorf("diagnostic leaked local path detail %q\ngot: %s", leak, got)
}
}
if !strings.Contains(got, "openclaw.cmd") {
t.Errorf("diagnostic should still name the shim's base name\ngot: %s", got)
}
if !strings.Contains(got, "1 entry") {
t.Errorf("diagnostic should summarise PATH as a count\ngot: %s", got)
}
}
// TestOpenclawShimDiagnosticStaysSilentOutOfScope pins the no-op cases. A
// diagnostic attached to a missing binary or a normal native executable would
// be actively misleading.
func TestOpenclawShimDiagnosticStaysSilentOutOfScope(t *testing.T) {
pathWithout(t)
realExit := exitError(t)
cases := []struct {
name string
bin string
err error
}{
{"native executable", `C:\npm\openclaw.exe`, realExit},
{"unix binary", "/usr/local/bin/openclaw", realExit},
{"binary not found", `C:\npm\openclaw.cmd`, exec.ErrNotFound},
{"nil error", `C:\npm\openclaw.cmd`, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := openclawShimDiagnostic(tc.bin, tc.err); got != "" {
t.Fatalf("expected no diagnostic, got: %s", got)
}
})
}
}
// TestOpenclawShimDiagnosticSurvivesWrappedError confirms the errors.As gate
// still fires when the exit error arrives wrapped, which is how it reaches this
// code once callers have annotated it.
func TestOpenclawShimDiagnosticSurvivesWrappedError(t *testing.T) {
pathWithout(t)
wrapped := errors.Join(errors.New("openclaw config file"), exitError(t))
shim := filepath.Join(t.TempDir(), "openclaw.cmd")
if got := openclawShimDiagnostic(shim, wrapped); got == "" {
t.Fatal("expected diagnostic through a wrapped exit error, got none")
}
}
// writeShim creates an executable named with a `.cmd` extension running body.
//
// On Unix the shebang makes a `.cmd`-named file genuinely executable, so the
// full execOpenclawCLI integration path is provable on the normal test job. The
// real npm-shim reproduction lives in the windows-tagged test file.
func writeShim(t *testing.T, dir, unixBody, windowsBody string) string {
t.Helper()
shim := filepath.Join(dir, "openclaw.cmd")
body := unixBody
if runtime.GOOS == "windows" {
body = windowsBody
}
if err := os.WriteFile(shim, []byte(body), 0o755); err != nil {
t.Fatalf("write shim: %v", err)
}
return shim
}
// TestExecOpenclawCLIAnnotatesSilentShimFailure is the end-to-end proof that
// the diagnostic reaches the error the daemon logs and reports. Before this
// change the message stopped at `exit status 1`, which is what left #6061's
// reporter running their own subprocess experiments to find the cause.
func TestExecOpenclawCLIAnnotatesSilentShimFailure(t *testing.T) {
shim := writeShim(t, t.TempDir(), "#!/bin/sh\nexit 1\n", "@echo off\r\nexit /b 1\r\n")
// Set PATH after creating the shim: the shim is invoked by absolute path,
// while the interpreter lookup must miss.
pathWithout(t)
_, err := execOpenclawCLI(context.Background(), shim, "config", "file")
if err == nil {
t.Fatal("expected the shim failure to surface as an error")
}
msg := err.Error()
if !strings.Contains(msg, "openclaw config file") {
t.Errorf("error should name the failing subcommand\ngot: %s", msg)
}
if !strings.Contains(msg, "resolves neither alongside the shim nor on the daemon PATH") {
t.Errorf("error should carry the shim diagnostic\ngot: %s", msg)
}
}
// TestExecOpenclawCLITimeoutIsNotMisdiagnosedAsMissingInterpreter is Sol-Boy's
// must-fix 1, exercised through the real code path rather than by handing the
// diagnostic a synthetic context error.
//
// openclawCLITimeout kills the child through CommandContext, and a killed
// process surfaces as *exec.ExitError ("signal: killed") — the same type a
// genuine exit 1 produces. Without checking the context first, a slow or hung
// CLI was reported as "node is not resolvable, install Node.js", pointing the
// user at something that was never broken.
//
// The shim sleeps only briefly on purpose. execOpenclawCLI sets no WaitDelay
// (see openclawCLITimeout's note on why that is left alone), so cmd.Output()
// stays parked until the output pipes os/exec manages for it — stdout AND
// stderr, since both are set to in-memory writers — reach EOF. A long sleep
// would just make this test hostage to that; it would not leak. The `sleep`
// here inherits those write ends and holds them until it exits, so by the time
// this call returns the helper has finished. (That is a property of this
// helper, not a general rule: a process may close its pipes and keep running.)
func TestExecOpenclawCLITimeoutIsNotMisdiagnosedAsMissingInterpreter(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("covered by TestWindowsOpenclawShimTimeoutIsNotMisdiagnosed with a real cmd.exe host")
}
// Resolve the blocking helper BEFORE PATH is stripped and embed it by
// absolute path. The shim has to keep running with an empty PATH, so it
// cannot rely on a PATH lookup of its own: `sh` on macOS quietly falls back
// to a default PATH, but dash on Linux does not, which made a PATH-relative
// `sleep` pass locally and fail in CI with "sleep: not found".
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", "")
pathWithout(t) // an interpreter lookup, if reached, would report "missing"
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")
}
msg := err.Error()
t.Logf("timeout error: %s", msg)
// The nit from round 2: the context error must be the wrapped cause, so
// standard cancellation checks work instead of only string matching.
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("errors.Is(err, context.DeadlineExceeded) must hold\ngot: %s", msg)
}
for _, forbidden := range []string{"install Node.js", "resolves neither", "the interpreter is reachable"} {
if strings.Contains(msg, forbidden) {
t.Errorf("timeout must not be diagnosed as an interpreter problem (found %q)\ngot: %s", forbidden, msg)
}
}
}
// TestExecOpenclawCLICancellationIsWrapped pins the same cancellation contract
// for an explicitly cancelled context, not just a deadline, so a caller can
// distinguish "we gave up" from "the CLI failed" without parsing strings.
func TestExecOpenclawCLICancellationIsWrapped(t *testing.T) {
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.WithCancel(context.Background())
go func() {
time.Sleep(100 * 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, context.Canceled) {
t.Errorf("errors.Is(err, context.Canceled) must hold\ngot: %s", err)
}
}
// TestExecOpenclawCLIPrefersRealStderr guarantees the diagnostic never masks a
// genuine message from the CLI. This is not hypothetical: windows-latest CI
// showed that a missing `node` DOES reach Go's stderr pipe as "'node' is not
// recognized", so on real Windows the missing-interpreter case takes this
// branch and the diagnostic is only a fallback for a truly silent failure.
func TestExecOpenclawCLIPrefersRealStderr(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("covered by the windows-tagged shim tests with a real cmd.exe host")
}
shim := writeShim(t, t.TempDir(), "#!/bin/sh\necho 'openclaw doctor says hello' >&2\nexit 1\n", "")
pathWithout(t)
_, err := execOpenclawCLI(context.Background(), shim, "config", "file")
if err == nil {
t.Fatal("expected the shim failure to surface as an error")
}
msg := err.Error()
if !strings.Contains(msg, "openclaw doctor says hello") {
t.Errorf("real stderr must be preserved\ngot: %s", msg)
}
if strings.Contains(msg, "no stderr output") {
t.Errorf("diagnostic must not fire when stderr is present\ngot: %s", msg)
}
}
// TestExecOpenclawCLIMissingTempDoesNotChangeOutcome pins the root cause #6061
// originally reported and then retracted. The reporter's own follow-up
// experiment showed `{PATH, SystemRoot}` alone succeeds, so TEMP/TMP must not
// be load-bearing for the OpenClaw CLI invocation. Locking that keeps a future
// change from quietly reintroducing a temp-dir dependency and resurrecting a
// root cause we already ruled out.
func TestExecOpenclawCLIMissingTempDoesNotChangeOutcome(t *testing.T) {
shim := writeShim(t, t.TempDir(),
"#!/bin/sh\necho '/tmp/openclaw/config.json'\n",
"@echo off\r\necho C:\\openclaw\\config.json\r\n",
)
t.Setenv("TEMP", "")
t.Setenv("TMP", "")
out, err := execOpenclawCLI(context.Background(), shim, "config", "file")
if err != nil {
t.Fatalf("invocation must not depend on TEMP/TMP: %v", err)
}
if strings.TrimSpace(out) == "" {
t.Fatal("expected the shim's stdout to be returned")
}
}
// TestExecOpenclawCLIHandlesShimInPathWithSpacesAndUnicode covers the install
// locations #6061's open questions flagged as unverified. A directory
// containing a space or non-ASCII characters must not break invocation or
// mangle the captured output.
func TestExecOpenclawCLIHandlesShimInPathWithSpacesAndUnicode(t *testing.T) {
for _, segment := range []string{"Program Files", "用户 開發", "café dir"} {
t.Run(segment, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), segment)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %q: %v", dir, err)
}
shim := writeShim(t, dir, "#!/bin/sh\necho 'ok-marker'\n", "@echo off\r\necho ok-marker\r\n")
out, err := execOpenclawCLI(context.Background(), shim, "config", "file")
if err != nil {
t.Fatalf("shim in %q should be invocable: %v", dir, err)
}
if !strings.Contains(out, "ok-marker") {
t.Fatalf("expected shim stdout to survive intact, got %q", out)
}
})
}
}