Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 33 additions & 44 deletions internal/driver/openclaw/driver.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
package openclaw

import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/docker/docker/api/types"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/mostlydev/clawdapus/internal/driver"
"github.com/mostlydev/clawdapus/internal/driver/shared"
"github.com/mostlydev/clawdapus/internal/health"
Expand Down Expand Up @@ -208,7 +205,15 @@ func (d *Driver) Materialize(rc *driver.ResolvedClaw, opts driver.MaterializeOpt
Restart: "on-failure",
SkillDir: "/claw/skills",
Healthcheck: &driver.Healthcheck{
Test: []string{"CMD", "openclaw", "health", "--json"},
// Current OpenClaw gateways expose unauthenticated liveness and
// readiness JSON, while the CLI health RPC requires the gateway's
// runtime credential. Validate the JSON body rather than accepting an
// arbitrary 200/SPA response. The CLI fallback keeps older images,
// which predate /readyz, on their established probe path.
Test: []string{
"CMD-SHELL",
`response="$(curl -fsS --max-time 2 http://localhost:18789/readyz 2>/dev/null)" || exec openclaw health --json >/dev/null 2>&1; printf '%s' "$$response" | jq -e 'has("ready")' >/dev/null 2>&1 || exec openclaw health --json >/dev/null 2>&1; printf '%s' "$$response" | jq -e '.ready == true' >/dev/null 2>&1`,
},
Interval: "30s",
Timeout: "10s",
Retries: 3,
Expand Down Expand Up @@ -299,66 +304,50 @@ func (d *Driver) HealthProbe(ref driver.ContainerRef) (*driver.Health, error) {
return &driver.Health{OK: false, Detail: fmt.Sprintf("container is not running (status: %s)", status)}, nil
}

execCfg := types.ExecConfig{
Cmd: []string{"openclaw", "health", "--json"},
AttachStdout: true,
AttachStderr: true,
}
execID, err := cli.ContainerExecCreate(ctx, ref.ContainerID, execCfg)
if err != nil {
return &driver.Health{OK: false, Detail: fmt.Sprintf("exec create failed: %v", err)}, nil
}

resp, err := cli.ContainerExecAttach(ctx, execID.ID, types.ExecStartCheck{})
if err != nil {
return &driver.Health{OK: false, Detail: fmt.Sprintf("exec attach failed: %v", err)}, nil
}
defer resp.Close()

var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer
copyDone := make(chan error, 1)
go func() {
_, copyErr := stdcopy.StdCopy(&stdoutBuf, &stderrBuf, resp.Reader)
copyDone <- copyErr
}()

select {
case copyErr := <-copyDone:
if copyErr != nil {
return &driver.Health{OK: false, Detail: fmt.Sprintf("exec read failed: %v", copyErr)}, nil
// Current gateways expose /readyz without the ephemeral gateway token that
// the CLI health RPC requires. A failed request or an unrecognized response
// falls back to the CLI for compatibility with older OpenClaw images. A
// valid ready:false response is authoritative and must not be masked.
stdout, _, exitCode, execErr := shared.ExecInContainer(ctx, cli, ref.ContainerID, []string{
"curl", "-fsS", "--max-time", "2", "http://localhost:18789/readyz",
})
if execErr == nil && exitCode == 0 {
if result, parseErr := health.ParseOpenClawReadinessJSON([]byte(stdout)); parseErr == nil {
return &driver.Health{OK: result.OK, Detail: result.Detail}, nil
}
case <-ctx.Done():
resp.Close()
return &driver.Health{OK: false, Detail: "health probe timed out after 15s"}, nil
}

execInspect, err := cli.ContainerExecInspect(ctx, execID.ID)
stdout, stderr, exitCode, err := shared.ExecInContainer(ctx, cli, ref.ContainerID, []string{
"openclaw", "health", "--json",
})
if err != nil {
return &driver.Health{OK: false, Detail: fmt.Sprintf("exec inspect failed: %v", err)}, nil
if ctx.Err() != nil {
return &driver.Health{OK: false, Detail: "health probe timed out after 15s"}, nil
}
return &driver.Health{OK: false, Detail: err.Error()}, nil
}
if execInspect.ExitCode != 0 {
detail := strings.TrimSpace(stderrBuf.String())
if exitCode != 0 {
detail := strings.TrimSpace(stderr)
if detail == "" {
detail = strings.TrimSpace(stdoutBuf.String())
detail = strings.TrimSpace(stdout)
}
if detail == "" {
detail = "health command failed with no output"
}
return &driver.Health{OK: false, Detail: fmt.Sprintf("health command exit code %d: %s", execInspect.ExitCode, detail)}, nil
return &driver.Health{OK: false, Detail: fmt.Sprintf("health command exit code %d: %s", exitCode, detail)}, nil
}

result, err := health.ParseHealthJSON(stdoutBuf.Bytes())
result, err := health.ParseHealthJSON([]byte(stdout))
if err != nil {
detail := fmt.Sprintf("parse failed: %v", err)
if stderr := strings.TrimSpace(stderrBuf.String()); stderr != "" {
if stderr = strings.TrimSpace(stderr); stderr != "" {
detail += fmt.Sprintf(" (stderr: %s)", stderr)
}
return &driver.Health{OK: false, Detail: detail}, nil
}

detail := result.Detail
if stderr := strings.TrimSpace(stderrBuf.String()); stderr != "" {
if stderr = strings.TrimSpace(stderr); stderr != "" {
detail += fmt.Sprintf(" (stderr: %s)", stderr)
}
return &driver.Health{OK: result.OK, Detail: detail}, nil
Expand Down
12 changes: 12 additions & 0 deletions internal/driver/openclaw/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package openclaw
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -143,6 +144,17 @@ func TestMaterializeWritesConfigAndReturnsResult(t *testing.T) {
t.Errorf("expected restart=on-failure, got %q", result.Restart)
}

wantHealthcheck := []string{
"CMD-SHELL",
`response="$(curl -fsS --max-time 2 http://localhost:18789/readyz 2>/dev/null)" || exec openclaw health --json >/dev/null 2>&1; printf '%s' "$$response" | jq -e 'has("ready")' >/dev/null 2>&1 || exec openclaw health --json >/dev/null 2>&1; printf '%s' "$$response" | jq -e '.ready == true' >/dev/null 2>&1`,
}
if result.Healthcheck == nil {
t.Fatal("expected OpenClaw healthcheck")
}
if got := result.Healthcheck.Test; !reflect.DeepEqual(got, wantHealthcheck) {
t.Fatalf("healthcheck test = %#v, want %#v", got, wantHealthcheck)
}

foundMemoryMount := false
for _, mount := range result.Mounts {
if mount.ContainerPath == shared.PortableMemoryDir {
Expand Down
56 changes: 48 additions & 8 deletions internal/health/openclaw.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,51 @@ type openclawHealth struct {
Version string `json:"version"`
}

// openclawReadiness is the JSON structure from the gateway's /readyz endpoint.
// Ready is a pointer so a response from an unrelated endpoint cannot be mistaken
// for a valid readiness result.
type openclawReadiness struct {
Ready *bool `json:"ready"`
Failing json.RawMessage `json:"failing"`
}

// ParseOpenClawReadinessJSON extracts readiness from the gateway's /readyz
// response. The endpoint does not require the runtime-generated gateway token.
func ParseOpenClawReadinessJSON(stdout []byte) (*HealthResult, error) {
jsonBytes, err := firstJSONObject(stdout)
if err != nil {
return nil, err
}

var readiness openclawReadiness
if err := json.Unmarshal(jsonBytes, &readiness); err != nil {
return nil, fmt.Errorf("health probe: failed to parse readiness JSON: %w", err)
}
if readiness.Ready == nil {
return nil, fmt.Errorf("health probe: readiness JSON has no ready field")
}

detail := "gateway ready"
if !*readiness.Ready {
detail = "gateway not ready"
failing := strings.TrimSpace(string(readiness.Failing))
if failing != "" && failing != "null" && failing != "[]" {
detail += ": " + failing
}
}

return &HealthResult{OK: *readiness.Ready, Detail: detail}, nil
}

// ParseHealthJSON extracts health status from stdout bytes.
// Handles leading noise by scanning for the first '{' character.
func ParseHealthJSON(stdout []byte) (*HealthResult, error) {
s := string(stdout)

idx := strings.Index(s, "{")
if idx < 0 {
return nil, fmt.Errorf("health probe: no JSON object found in output")
jsonBytes, err := firstJSONObject(stdout)
if err != nil {
return nil, err
}

jsonStr := s[idx:]
var h openclawHealth
if err := json.Unmarshal([]byte(jsonStr), &h); err != nil {
if err := json.Unmarshal(jsonBytes, &h); err != nil {
return nil, fmt.Errorf("health probe: failed to parse JSON: %w", err)
}

Expand All @@ -40,3 +72,11 @@ func ParseHealthJSON(stdout []byte) (*HealthResult, error) {
Detail: h.Detail,
}, nil
}

func firstJSONObject(stdout []byte) ([]byte, error) {
idx := strings.IndexByte(string(stdout), '{')
if idx < 0 {
return nil, fmt.Errorf("health probe: no JSON object found in output")
}
return stdout[idx:], nil
}
33 changes: 33 additions & 0 deletions internal/health/openclaw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,39 @@ package health

import "testing"

func TestParseOpenClawReadinessJSONReady(t *testing.T) {
result, err := ParseOpenClawReadinessJSON([]byte(`{"ready":true,"failing":[]}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.OK {
t.Error("expected OK=true")
}
if result.Detail != "gateway ready" {
t.Errorf("expected gateway-ready detail, got %q", result.Detail)
}
}

func TestParseOpenClawReadinessJSONNotReady(t *testing.T) {
result, err := ParseOpenClawReadinessJSON([]byte(`{"ready":false,"failing":["discord"]}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.OK {
t.Error("expected OK=false")
}
if result.Detail != `gateway not ready: ["discord"]` {
t.Errorf("expected failing-component detail, got %q", result.Detail)
}
}

func TestParseOpenClawReadinessJSONRequiresReadyField(t *testing.T) {
_, err := ParseOpenClawReadinessJSON([]byte(`{"status":"ok"}`))
if err == nil {
t.Fatal("expected error when readiness field is absent")
}
}

func TestParseHealthJSONClean(t *testing.T) {
stdout := `{"status":"ok","version":"2026.2.9"}`
result, err := ParseHealthJSON([]byte(stdout))
Expand Down
Loading