mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-04 14:13:15 +00:00
* 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>
196 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|