diff --git a/internal/driver/openclaw/jobs.go b/internal/driver/openclaw/jobs.go index 6a289fa..f9406f6 100644 --- a/internal/driver/openclaw/jobs.go +++ b/internal/driver/openclaw/jobs.go @@ -4,12 +4,15 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "strconv" "strings" "time" "github.com/mostlydev/clawdapus/internal/driver" ) +const defaultInvocationTimeoutSeconds = 300 + type job struct { ID string `json:"id"` AgentID string `json:"agentId"` @@ -61,6 +64,10 @@ type jobState struct { // so re-running claw up is idempotent. func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) { now := time.Now().UnixMilli() + timeoutSeconds, err := resolvedInvocationTimeoutSeconds(rc) + if err != nil { + return nil, err + } jobs := make([]job, 0, len(rc.Invocations)) timezone := strings.TrimSpace(rc.Timezone) if timezone == "" { @@ -85,7 +92,7 @@ func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) { Schedule: jobSchedule{Expr: inv.Schedule, TZ: timezone, Kind: "cron"}, SessionTarget: "isolated", WakeMode: "now", - Payload: jobPayload{Kind: "agentTurn", Message: inv.Message, TimeoutSeconds: 300}, + Payload: jobPayload{Kind: "agentTurn", Message: inv.Message, TimeoutSeconds: timeoutSeconds}, Delivery: jobDelivery{Mode: "announce", BestEffort: true, To: inv.To}, State: jobState{}, } @@ -97,6 +104,46 @@ func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) { }, "", " ") } +func resolvedInvocationTimeoutSeconds(rc *driver.ResolvedClaw) (int, error) { + timeoutSeconds := defaultInvocationTimeoutSeconds + if rc == nil { + return timeoutSeconds, nil + } + for _, cmd := range rc.Configures { + path, value, err := parseConfigSetCommand(cmd) + if err != nil { + return 0, fmt.Errorf("jobs generation: %w", err) + } + if path != "agents.defaults.timeoutSeconds" { + continue + } + timeoutSeconds, err = positiveIntConfigValue(path, value) + if err != nil { + return 0, fmt.Errorf("jobs generation: %w", err) + } + } + return timeoutSeconds, nil +} + +func positiveIntConfigValue(path string, value any) (int, error) { + switch v := value.(type) { + case float64: + parsed := int(v) + if float64(parsed) != v || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer, got %v", path, value) + } + return parsed, nil + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(v)) + if err != nil || parsed <= 0 { + return 0, fmt.Errorf("%s must be a positive integer, got %q", path, v) + } + return parsed, nil + default: + return 0, fmt.Errorf("%s must be a positive integer, got %T", path, value) + } +} + func deterministicJobID(parts ...string) string { h := sha256.Sum256([]byte(strings.Join(parts, "|"))) return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", diff --git a/internal/driver/openclaw/jobs_test.go b/internal/driver/openclaw/jobs_test.go index 56561b3..229207f 100644 --- a/internal/driver/openclaw/jobs_test.go +++ b/internal/driver/openclaw/jobs_test.go @@ -68,6 +68,9 @@ func TestGenerateJobsJSONSingleInvocation(t *testing.T) { if payload["message"] != "Pre-market synthesis" { t.Errorf("expected payload.message=%q, got %v", "Pre-market synthesis", payload["message"]) } + if payload["timeoutSeconds"] != float64(300) { + t.Errorf("expected payload.timeoutSeconds=300, got %v", payload["timeoutSeconds"]) + } delivery := j["delivery"].(map[string]interface{}) if delivery["to"] != "111222333444" { @@ -187,6 +190,49 @@ func TestGenerateJobsJSONDisablesPodOriginJobs(t *testing.T) { } } +func TestGenerateJobsJSONUsesConfiguredAgentTimeout(t *testing.T) { + rc := &driver.ResolvedClaw{ + ServiceName: "allen", + Configures: []string{ + "openclaw config set agents.defaults.timeoutSeconds 900", + }, + Invocations: []driver.Invocation{ + { + Schedule: "0 7 * * 1-5", + Message: "Morning research scan", + }, + }, + } + data, err := GenerateJobsJSON(rc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + jobs := parseJobStore(t, data) + payload := jobs[0]["payload"].(map[string]interface{}) + if payload["timeoutSeconds"] != float64(900) { + t.Fatalf("expected configured payload timeoutSeconds=900, got %v", payload["timeoutSeconds"]) + } +} + +func TestGenerateJobsJSONRejectsInvalidConfiguredAgentTimeout(t *testing.T) { + rc := &driver.ResolvedClaw{ + ServiceName: "allen", + Configures: []string{ + `openclaw config set agents.defaults.timeoutSeconds "slow"`, + }, + Invocations: []driver.Invocation{ + { + Schedule: "0 7 * * 1-5", + Message: "Morning research scan", + }, + }, + } + if _, err := GenerateJobsJSON(rc); err == nil { + t.Fatal("expected invalid timeoutSeconds configure to fail") + } +} + func TestGenerateJobsJSONUsesResolvedTimezone(t *testing.T) { rc := &driver.ResolvedClaw{ ServiceName: "tiverton", diff --git a/internal/driver/shared/memory.go b/internal/driver/shared/memory.go index f2aa9d7..97fdf8a 100644 --- a/internal/driver/shared/memory.go +++ b/internal/driver/shared/memory.go @@ -34,14 +34,41 @@ func PreparePortableMemory(stateDir string, extraImportRoots ...string) (string, } else if !os.IsNotExist(err) { return "", fmt.Errorf("stat portable memory file %q: %w", path, err) } - if err := os.WriteFile(path, nil, 0o644); err != nil { + if err := os.WriteFile(path, nil, 0o666); err != nil { return "", fmt.Errorf("seed portable memory file %q: %w", path, err) } } + if err := normalizePortableMemoryPermissions(memoryDir); err != nil { + return "", err + } return memoryDir, nil } +func normalizePortableMemoryPermissions(memoryDir string) error { + return filepath.Walk(memoryDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return fmt.Errorf("walk portable memory %q: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return nil + } + if info.IsDir() { + if err := os.Chmod(path, 0o777); err != nil { + return fmt.Errorf("chmod portable memory dir %q: %w", path, err) + } + return nil + } + if !info.Mode().IsRegular() { + return nil + } + if err := os.Chmod(path, 0o666); err != nil { + return fmt.Errorf("chmod portable memory file %q: %w", path, err) + } + return nil + }) +} + func legacyPortableMemoryDirs(runtimeDir string) []string { return []string{ filepath.Join(runtimeDir, "hermes-home", "memories"), diff --git a/internal/driver/shared/memory_test.go b/internal/driver/shared/memory_test.go index 9e38f0a..4385982 100644 --- a/internal/driver/shared/memory_test.go +++ b/internal/driver/shared/memory_test.go @@ -114,3 +114,44 @@ func TestPreparePortableMemoryImportsFromExtraRuntimeRoot(t *testing.T) { t.Fatalf("unexpected migrated memory: %q", string(data)) } } + +func TestPreparePortableMemoryNormalizesExistingPermissions(t *testing.T) { + stateDir := t.TempDir() + memoryDir := filepath.Join(stateDir, "memory") + nestedDir := filepath.Join(memoryDir, "notes") + + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(memoryDir, "MEMORY.md"), []byte("canonical"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedDir, "2026-04-10.md"), []byte("note"), 0o640); err != nil { + t.Fatal(err) + } + + gotDir, err := PreparePortableMemory(stateDir) + if err != nil { + t.Fatalf("PreparePortableMemory returned error: %v", err) + } + if gotDir != memoryDir { + t.Fatalf("unexpected memory dir: %q", gotDir) + } + + checks := map[string]os.FileMode{ + memoryDir: 0o777, + filepath.Join(memoryDir, "MEMORY.md"): 0o666, + filepath.Join(memoryDir, "USER.md"): 0o666, + nestedDir: 0o777, + filepath.Join(nestedDir, "2026-04-10.md"): 0o666, + } + for path, want := range checks { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat %s: %v", path, err) + } + if got := info.Mode().Perm(); got != want { + t.Fatalf("%s mode=%o want %o", path, got, want) + } + } +}