Files
goclaw/internal/tools/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

196 lines
6.1 KiB
Go

package tools
import (
"context"
"strings"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type testCronStore struct {
jobs map[string]*store.CronJob
updateCnt int
runCnt int
lastForce bool
updateErr error
runErr error
}
func newTestCronStore(job *store.CronJob) *testCronStore {
jobs := map[string]*store.CronJob{}
if job != nil {
jobs[job.ID] = job
}
return &testCronStore{jobs: jobs}
}
func (s *testCronStore) AddJob(context.Context, string, store.CronSchedule, string, bool, string, string, string, string) (*store.CronJob, error) {
return nil, nil
}
func (s *testCronStore) GetJob(_ context.Context, jobID string) (*store.CronJob, bool) {
job, ok := s.jobs[jobID]
return job, ok
}
func (s *testCronStore) ListJobs(context.Context, bool, string, string) []store.CronJob {
var jobs []store.CronJob
for _, job := range s.jobs {
jobs = append(jobs, *job)
}
return jobs
}
func (s *testCronStore) RemoveJob(context.Context, string) error { return nil }
func (s *testCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
s.updateCnt++
if s.updateErr != nil {
return nil, s.updateErr
}
job := s.jobs[jobID]
if patch.Message != "" {
job.Payload.Message = patch.Message
}
return job, nil
}
func (s *testCronStore) EnableJob(context.Context, string, bool) error { return nil }
func (s *testCronStore) GetRunLog(context.Context, string, int, int) ([]store.CronRunLogEntry, int) {
return nil, 0
}
func (s *testCronStore) Status() map[string]any { return map[string]any{} }
func (s *testCronStore) Start() error { return nil }
func (s *testCronStore) Stop() {}
func (s *testCronStore) SetOnJob(func(*store.CronJob) (*store.CronJobResult, error)) {}
func (s *testCronStore) SetOnEvent(func(store.CronEvent)) {}
func (s *testCronStore) RunJob(_ context.Context, _ string, force bool) (bool, string, error) {
s.runCnt++
s.lastForce = force
return true, "", s.runErr
}
func (s *testCronStore) GetDueJobs(time.Time) []store.CronJob { return nil }
func (s *testCronStore) SetDefaultTimezone(string) {}
func TestCronToolBlocksCredentialBoundUpdateByDifferentUser(t *testing.T) {
cronStore := newTestCronStore(&store.CronJob{
ID: "job-1",
UserID: "group:telegram:-100123",
Payload: store.CronPayload{
Message: "old",
CredentialUserID: "tenant-user-a",
},
})
tool := NewCronTool(cronStore)
ctx := store.WithUserID(context.Background(), "group:telegram:-100123")
ctx = store.WithCredentialUserID(ctx, "tenant-user-b")
result := tool.Execute(ctx, map[string]any{
"action": "update",
"jobId": "job-1",
"patch": map[string]any{"message": "run gh issue list"},
})
if !result.IsError || !strings.Contains(result.ForLLM, "credential context") {
t.Fatalf("expected credential context error, got %#v", result)
}
if cronStore.updateCnt != 0 {
t.Fatalf("UpdateJob called %d times, want 0", cronStore.updateCnt)
}
}
func TestCronToolListRedactsCredentialUserID(t *testing.T) {
cronStore := newTestCronStore(&store.CronJob{
ID: "job-1",
UserID: "group:telegram:-100123",
Payload: store.CronPayload{
Message: "run gh issue list",
CredentialUserID: "tenant-user-a",
},
})
tool := NewCronTool(cronStore)
ctx := store.WithUserID(context.Background(), "group:telegram:-100123")
result := tool.Execute(ctx, map[string]any{"action": "list", "includeDisabled": true})
if result.IsError {
t.Fatalf("list returned error: %#v", result)
}
if strings.Contains(result.ForLLM, "tenant-user-a") || strings.Contains(result.ForLLM, "credentialUserId") {
t.Fatalf("credential identity leaked in list response: %s", result.ForLLM)
}
}
func TestCronToolBlocksCredentialBoundRunByDifferentUser(t *testing.T) {
cronStore := newTestCronStore(&store.CronJob{
ID: "job-1",
UserID: "group:telegram:-100123",
Payload: store.CronPayload{
Message: "run gh issue list",
CredentialUserID: "tenant-user-a",
},
})
tool := NewCronTool(cronStore)
ctx := store.WithUserID(context.Background(), "group:telegram:-100123")
ctx = store.WithCredentialUserID(ctx, "tenant-user-b")
result := tool.Execute(ctx, map[string]any{
"action": "run",
"jobId": "job-1",
"runMode": "force",
})
if !result.IsError || !strings.Contains(result.ForLLM, "credential context") {
t.Fatalf("expected credential context error, got %#v", result)
}
if cronStore.runCnt != 0 {
t.Fatalf("RunJob called %d times, want 0", cronStore.runCnt)
}
}
// A command payload must not be slipped in via update when command cron is
// disabled — the disabled-gateway contract is that command jobs cannot be
// created OR mutated into existence.
func TestCronToolUpdateBlocksCommandWhenDisabled(t *testing.T) {
cronStore := newTestCronStore(&store.CronJob{ID: "job-1", Payload: store.CronPayload{Message: "old"}})
tool := NewCronTool(cronStore) // commandEnabled defaults to false
result := tool.Execute(context.Background(), map[string]any{
"action": "update",
"jobId": "job-1",
"patch": map[string]any{"commandArgv": []any{"echo", "hi"}},
})
if !result.IsError || !strings.Contains(result.ForLLM, "disabled") {
t.Fatalf("expected command-disabled error, got %#v", result)
}
if cronStore.updateCnt != 0 {
t.Fatalf("UpdateJob called %d times, want 0", cronStore.updateCnt)
}
}
// Update must validate the command spec; an invalid argv must be rejected even
// when command cron is enabled.
func TestCronToolUpdateRejectsInvalidCommandSpec(t *testing.T) {
cronStore := newTestCronStore(&store.CronJob{ID: "job-1", Payload: store.CronPayload{Message: "old"}})
tool := NewCronTool(cronStore)
tool.SetCommandEnabled(true)
result := tool.Execute(context.Background(), map[string]any{
"action": "update",
"jobId": "job-1",
"patch": map[string]any{"commandArgv": []any{""}}, // argv[0] empty → invalid
})
if !result.IsError {
t.Fatalf("expected invalid command spec error, got %#v", result)
}
if cronStore.updateCnt != 0 {
t.Fatalf("UpdateJob called %d times, want 0", cronStore.updateCnt)
}
}