MUL-3136 fix(openclaw): parse config path from last non-empty line of CLI output

Fix OpenClaw config discovery when `openclaw config file` prints Doctor warning UI before the actual config path. The daemon now uses the last non-empty stdout line as the path while preserving the existing tilde expansion, absolute-path validation, stat checks, and fail-closed behavior.

Tests: go test ./internal/daemon/execenv
This commit is contained in:
liujianqiang-niu
2026-06-08 17:22:02 +08:00
committed by GitHub
parent d434f038c9
commit 5be7d1bc17
2 changed files with 64 additions and 1 deletions

View File

@@ -351,7 +351,17 @@ func openclawActiveConfigPath(bin string, timeout time.Duration) (string, bool,
if err != nil {
return "", false, err
}
path := strings.TrimSpace(out)
// OpenClaw may print terminal UI borders (e.g., Doctor warnings) before
// the actual path. The path is always the last non-empty line.
lines := strings.Split(strings.TrimSpace(out), "\n")
path := ""
for i := len(lines) - 1; i >= 0; i-- {
trimmed := strings.TrimSpace(lines[i])
if trimmed != "" {
path = trimmed
break
}
}
if path == "" {
return "", false, fmt.Errorf("`openclaw config file` returned empty output")
}

View File

@@ -349,6 +349,59 @@ func TestPrepareOpenclawConfigExpandsTilde(t *testing.T) {
}
}
// TestPrepareOpenclawConfigParsesPathFromUITerminalOutput — regression test
// for the case where `openclaw config file` prints terminal UI borders
// (e.g., Doctor warnings) before the actual path. The path is always the
// last non-empty line.
func TestPrepareOpenclawConfigParsesPathFromUITerminalOutput(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)
}
userConfigDir := t.TempDir()
userConfigPath := filepath.Join(userConfigDir, "openclaw.json")
if err := os.WriteFile(userConfigPath, []byte(`{}`), 0o600); err != nil {
t.Fatalf("write user cfg: %v", err)
}
// Simulate OpenClaw's output with UI borders (Doctor warnings)
stdoutWithUI := `
◇ Doctor warnings ──────────────────────────────────────────────────────╮
│ │
│ - Left plugin install index in place because shared SQLite state has │
│ conflicting plugin install metadata for: qqbot │
│ │
├────────────────────────────────────────────────────────────────────────╯
[state-migrations] Legacy state migration warnings:
- Left plugin install index in place because shared SQLite state has conflicting plugin install metadata for: qqbot
◇ Doctor warnings ──────────────────────────────────────────────────────╮
│ │
│ - Left plugin install index in place because shared SQLite state has │
│ conflicting plugin install metadata for: qqbot │
│ │
├────────────────────────────────────────────────────────────────────────╯
` + userConfigPath + "\n"
stub := installOpenclawStub(t, map[string]openclawResponse{
"config file": {stdout: stdoutWithUI},
"config get agents.list --json": {stdout: "null"},
})
result, err := prepareOpenclawConfig(envRoot, workDir, OpenclawConfigPrep{OpenclawBin: stub.bin})
if err != nil {
t.Fatalf("prepareOpenclawConfig: %v", err)
}
got := mustReadJSON(t, result.ConfigPath)
include := got["$include"].([]any)
if include[0] != userConfigPath {
t.Errorf("$include[0] = %v, want %q (path must be extracted from last non-empty line)", include[0], userConfigPath)
}
}
// TestPrepareOpenclawConfigWrapperLoadableUnderIncludeConfinement is the
// regression test for the Elon include-confinement blocker. OpenClaw
// resolves `$include` only inside the wrapper file's own directory unless