Files
goclaw/internal/gateway/methods/cron_test.go
T
Zezae OhandClaude Opus 4.8 4a79c8a208 feat(cron): deterministic command payloads (run a shell command, no LLM) (#1279)
* feat(cron): deterministic command payloads (run a shell command, no LLM)

Cron jobs always run an agent turn today, so deterministic work (health
probes, backups, syncs) pays model tokens on every fire. This adds a
"command" payload kind that runs a shell command directly in the gateway
process with zero model tokens, mirroring openclaw's command cron.

- store: CronPayload.Command (*CronCommandSpec — argv/cwd/env/input/
  timeouts/output cap). Persists in the existing payload JSON blob, so
  there is NO migration and no schema version bump.
- internal/cronexec: in-process runner with wall-clock + no-output
  timeouts, per-stream output capping, and process-group termination so a
  timed-out command's forked children are also killed.
- gateway_cron handler: command jobs run in-process and deliver stdout on
  success (honoring the NO_REPLY sentinel). A non-zero exit / timeout
  returns an error so the run is recorded as error and retried per
  cron.max_retries; failures are NOT delivered, mirroring the agent path
  (only successful output is announced — no channel spam).
- surfaces: cron.create RPC, the agent `cron` tool, and a new
  `goclaw cron create` CLI all accept command payloads.
- security: gated by cron.command_enabled (default false). Commands run
  with the gateway process's privileges, so the feature is opt-in per
  gateway; when disabled the RPC and tool reject command payloads and the
  handler refuses to run them.
- i18n (en/vi/zh), docs (08-scheduling-cron.md), and tests for the runner
  and the handler command path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cron): gate command payloads on the update surfaces too

handleUpdate (RPC + agent tool) passed CronJobPatch.Command straight to
UpdateJob, which switches the payload to command kind for any non-nil
Command — without the command_enabled gate or ValidateCronCommandSpec that
create enforces. A normal job could therefore be mutated into a command job
(or persisted with an invalid spec, e.g. empty argv) on a gateway where
command cron is disabled, breaking the disabled-gateway contract.

Both update surfaces now require cron.command_enabled and validate the spec
before UpdateJob, matching create. The agent tool parses the command via the
same path as add and drops the raw keys so a shell-string command can't break
the generic patch unmarshal. Regression tests added for RPC and tool update
(command disabled + invalid argv), plus a positive enabled-valid case.

Addresses review feedback from @mrgoonie on #1279.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 10:23:55 +07:00

417 lines
12 KiB
Go

package methods
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// ---- stub CronStore ----
type stubCronStore struct {
jobs map[string]*store.CronJob
addErr error
removeErr error
updateErr error
enableErr error
addedJob *store.CronJob
updateCnt int
enableCnt int
runCnt int
}
func newStubCronStore() *stubCronStore {
return &stubCronStore{jobs: make(map[string]*store.CronJob)}
}
func (s *stubCronStore) addJob(id, name, userID string) {
s.jobs[id] = &store.CronJob{ID: id, Name: name, UserID: userID, Enabled: true}
}
func (s *stubCronStore) AddJob(_ context.Context, name string, schedule store.CronSchedule, message string,
deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) {
if s.addErr != nil {
return nil, s.addErr
}
job := &store.CronJob{
ID: "new-job-id",
Name: name,
UserID: userID,
Enabled: true,
Schedule: schedule,
}
s.addedJob = job
s.jobs[job.ID] = job
return job, nil
}
func (s *stubCronStore) GetJob(_ context.Context, jobID string) (*store.CronJob, bool) {
j, ok := s.jobs[jobID]
return j, ok
}
func (s *stubCronStore) ListJobs(_ context.Context, _ bool, _, userID string) []store.CronJob {
var result []store.CronJob
for _, j := range s.jobs {
if userID == "" || j.UserID == userID {
result = append(result, *j)
}
}
return result
}
func (s *stubCronStore) RemoveJob(_ context.Context, jobID string) error {
if s.removeErr != nil {
return s.removeErr
}
delete(s.jobs, jobID)
return nil
}
func (s *stubCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
s.updateCnt++
if s.updateErr != nil {
return nil, s.updateErr
}
j, ok := s.jobs[jobID]
if !ok {
return nil, store.ErrCronJobNotFound
}
return j, nil
}
func (s *stubCronStore) EnableJob(_ context.Context, jobID string, enabled bool) error {
s.enableCnt++
if s.enableErr != nil {
return s.enableErr
}
if j, ok := s.jobs[jobID]; ok {
j.Enabled = enabled
}
return nil
}
func (s *stubCronStore) GetRunLog(_ context.Context, _ string, _, _ int) ([]store.CronRunLogEntry, int) {
return nil, 0
}
func (s *stubCronStore) Status() map[string]any { return map[string]any{"running": true} }
// Lifecycle stubs (not called in unit tests)
func (s *stubCronStore) Start() error { return nil }
func (s *stubCronStore) Stop() {}
func (s *stubCronStore) SetOnJob(_ func(*store.CronJob) (*store.CronJobResult, error)) {}
func (s *stubCronStore) SetOnEvent(_ func(store.CronEvent)) {}
func (s *stubCronStore) RunJob(_ context.Context, _ string, _ bool) (bool, string, error) {
s.runCnt++
return true, "", nil
}
func (s *stubCronStore) SetDefaultTimezone(_ string) {}
func (s *stubCronStore) GetDueJobs(_ time.Time) []store.CronJob { return nil }
// ---- helpers ----
func buildCronMethods(t *testing.T, svc *stubCronStore) *CronMethods {
t.Helper()
cfg := &config.Config{}
return NewCronMethods(svc, &stubEventPub{}, cfg)
}
func cronReqFrame(t *testing.T, method string, params map[string]any) *protocol.RequestFrame {
t.Helper()
raw, err := json.Marshal(params)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return &protocol.RequestFrame{
Type: protocol.FrameTypeRequest,
ID: "cron-req-1",
Method: method,
Params: raw,
}
}
// ---- Tests: handleList ----
func TestCronList_EmptyStore_ReturnsEmptyJobs(t *testing.T) {
svc := newStubCronStore()
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronList, map[string]any{})
m.handleList(context.Background(), client, req)
// No panic = success
}
func TestCronList_WithJobs_ReturnsAll(t *testing.T) {
svc := newStubCronStore()
svc.addJob("job-1", "my-job", "user-x")
svc.addJob("job-2", "another-job", "user-y")
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronList, map[string]any{"includeDisabled": false})
m.handleList(context.Background(), client, req)
// No panic = success
}
// ---- Tests: handleCreate ----
func TestCronCreate_MissingName_ReturnsInvalidRequest(t *testing.T) {
svc := newStubCronStore()
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
"message": "hello",
"schedule": map[string]any{"kind": "every", "everyMs": 60000},
})
m.handleCreate(context.Background(), client, req)
// No panic = name-required error path hit
}
func TestCronCreate_MissingMessage_ReturnsInvalidRequest(t *testing.T) {
svc := newStubCronStore()
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
"name": "my-job",
"schedule": map[string]any{"kind": "every", "everyMs": 60000},
// message intentionally omitted
})
m.handleCreate(context.Background(), client, req)
// No panic = message-required error path hit
}
func TestCronCreate_InvalidSlug_ReturnsInvalidRequest(t *testing.T) {
svc := newStubCronStore()
m := buildCronMethods(t, svc)
client := nullClient()
// Name contains uppercase — invalid slug
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
"name": "My Invalid Name!",
"message": "do something",
"schedule": map[string]any{"kind": "every", "everyMs": 60000},
})
m.handleCreate(context.Background(), client, req)
// No panic = invalid-slug error path hit
}
func TestCronCreate_ValidParams_CreatesJob(t *testing.T) {
svc := newStubCronStore()
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronCreate, map[string]any{
"name": "my-daily-job",
"message": "do the thing",
"schedule": map[string]any{"kind": "every", "everyMs": 3600000},
})
m.handleCreate(context.Background(), client, req)
// No panic = successful create path hit
if svc.addedJob == nil {
t.Error("expected AddJob to be called")
}
if svc.addedJob != nil && svc.addedJob.Name != "my-daily-job" {
t.Errorf("job name = %q, want %q", svc.addedJob.Name, "my-daily-job")
}
}
// ---- Tests: handleDelete ----
func TestCronDelete_MissingJobID_ReturnsInvalidRequest(t *testing.T) {
svc := newStubCronStore()
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{})
m.handleDelete(context.Background(), client, req)
// No panic = jobId-required error path hit
}
func TestCronDelete_UserOwnsJob_DeletesJob(t *testing.T) {
svc := newStubCronStore()
// nullClient().UserID() returns "" — match the job's userID to that so ownership check passes.
svc.addJob("del-job", "to-delete", "")
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{"jobId": "del-job"})
m.handleDelete(context.Background(), client, req)
if _, exists := svc.jobs["del-job"]; exists {
t.Error("expected job to be deleted from store")
}
}
func TestCronDelete_NonExistentJob_NonAdminPath_ReturnsUnauthorized(t *testing.T) {
svc := newStubCronStore()
// No job with that ID exists — non-admin client will get unauthorized
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronDelete, map[string]any{"jobId": "ghost-job"})
m.handleDelete(context.Background(), client, req)
// No panic = unauthorized path hit
}
// ---- Tests: handleToggle ----
func TestCronToggle_MissingJobID_ReturnsInvalidRequest(t *testing.T) {
svc := newStubCronStore()
m := buildCronMethods(t, svc)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"enabled": true})
m.handleToggle(context.Background(), client, req)
// No panic = jobId-required error path hit
}
func TestCronToggle_BlocksCredentialBoundEnableByDifferentUser(t *testing.T) {
svc := newStubCronStore()
svc.jobs["job-1"] = &store.CronJob{
ID: "job-1",
UserID: "",
Payload: store.CronPayload{
CredentialUserID: "tenant-user-a",
},
}
m := buildCronMethods(t, svc)
client := nullClient()
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": true})
m.handleToggle(ctx, client, req)
if svc.enableCnt != 0 {
t.Fatalf("EnableJob called %d times, want 0", svc.enableCnt)
}
}
func TestCronToggle_AllowsCredentialBoundDisableByDifferentUser(t *testing.T) {
svc := newStubCronStore()
svc.jobs["job-1"] = &store.CronJob{
ID: "job-1",
UserID: "",
Payload: store.CronPayload{
CredentialUserID: "tenant-user-a",
},
}
m := buildCronMethods(t, svc)
client := nullClient()
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
req := cronReqFrame(t, protocol.MethodCronToggle, map[string]any{"jobId": "job-1", "enabled": false})
m.handleToggle(ctx, client, req)
if svc.enableCnt != 1 {
t.Fatalf("EnableJob called %d times, want 1", svc.enableCnt)
}
}
func TestCronUpdate_BlocksCredentialBoundJobByDifferentUser(t *testing.T) {
svc := newStubCronStore()
svc.jobs["job-1"] = &store.CronJob{
ID: "job-1",
UserID: "",
Payload: store.CronPayload{
CredentialUserID: "tenant-user-a",
},
}
m := buildCronMethods(t, svc)
client := nullClient()
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
"jobId": "job-1",
"patch": map[string]any{"message": "run gh issue list"},
})
m.handleUpdate(ctx, client, req)
if svc.updateCnt != 0 {
t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt)
}
}
func TestCronRun_BlocksCredentialBoundJobByDifferentUser(t *testing.T) {
svc := newStubCronStore()
svc.jobs["job-1"] = &store.CronJob{
ID: "job-1",
UserID: "",
Payload: store.CronPayload{
CredentialUserID: "tenant-user-a",
},
}
m := buildCronMethods(t, svc)
client := nullClient()
ctx := store.WithCredentialUserID(context.Background(), "tenant-user-b")
req := cronReqFrame(t, protocol.MethodCronRun, map[string]any{"jobId": "job-1", "mode": "force"})
m.handleRun(ctx, client, req)
if svc.runCnt != 0 {
t.Fatalf("RunJob called %d times, want 0", svc.runCnt)
}
}
// ---- Tests: handleUpdate command gate ----
// A normal job must not be mutable into a command job when the gateway has
// command cron disabled.
func TestCronUpdate_BlocksCommandWhenDisabled(t *testing.T) {
svc := newStubCronStore()
svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""}
m := buildCronMethods(t, svc) // cfg.Cron.CommandEnabled defaults to false
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
"jobId": "job-1",
"patch": map[string]any{"command": map[string]any{"argv": []any{"echo", "hi"}}},
})
m.handleUpdate(context.Background(), client, req)
if svc.updateCnt != 0 {
t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt)
}
}
// Update must validate the command spec; an empty argv must be rejected even
// when command cron is enabled.
func TestCronUpdate_RejectsInvalidCommandSpec(t *testing.T) {
svc := newStubCronStore()
svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""}
cfg := &config.Config{}
cfg.Cron.CommandEnabled = true
m := NewCronMethods(svc, &stubEventPub{}, cfg)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
"jobId": "job-1",
"patch": map[string]any{"command": map[string]any{"argv": []any{}}}, // empty argv → invalid
})
m.handleUpdate(context.Background(), client, req)
if svc.updateCnt != 0 {
t.Fatalf("UpdateJob called %d times, want 0", svc.updateCnt)
}
}
// A valid command payload on update is accepted when command cron is enabled.
func TestCronUpdate_EnabledValidCommand_Updates(t *testing.T) {
svc := newStubCronStore()
svc.jobs["job-1"] = &store.CronJob{ID: "job-1", UserID: ""}
cfg := &config.Config{}
cfg.Cron.CommandEnabled = true
m := NewCronMethods(svc, &stubEventPub{}, cfg)
client := nullClient()
req := cronReqFrame(t, protocol.MethodCronUpdate, map[string]any{
"jobId": "job-1",
"patch": map[string]any{"command": map[string]any{"argv": []any{"df", "-h"}}},
})
m.handleUpdate(context.Background(), client, req)
if svc.updateCnt != 1 {
t.Fatalf("UpdateJob called %d times, want 1", svc.updateCnt)
}
}