Files
goclaw/cmd/cron_cmd.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

317 lines
8.7 KiB
Go

package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
func cronCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "cron",
Short: "Manage scheduled cron jobs",
}
cmd.AddCommand(cronListCmd())
cmd.AddCommand(cronCreateCmd())
cmd.AddCommand(cronDeleteCmd())
cmd.AddCommand(cronToggleCmd())
return cmd
}
func cronCreateCmd() *cobra.Command {
var (
name string
cronExpr string
every string
at string
tz string
command string
argvJSON string
cwd string
timeout string
envPairs []string
deliver bool
channel string
to string
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a deterministic command cron job (runs a shell command, no LLM)",
Long: "Create a cron job whose payload is a shell command executed in the gateway\n" +
"process WITHOUT an LLM turn (zero model tokens). Requires the gateway to have\n" +
"cron.command_enabled=true.\n\n" +
"Examples:\n" +
" goclaw cron create --name disk-probe --cron '*/15 * * * *' --command 'df -h /'\n" +
" goclaw cron create --name backup --at 2026-07-01T09:00:00Z --argv '[\"/opt/backup.sh\"]' --timeout 5m",
Run: func(cmd *cobra.Command, args []string) {
if name == "" {
fmt.Fprintln(os.Stderr, "Error: --name is required")
os.Exit(1)
}
schedule := map[string]any{}
switch {
case cronExpr != "":
schedule["kind"] = "cron"
schedule["expr"] = cronExpr
if tz != "" {
schedule["tz"] = tz
}
case every != "":
d, err := time.ParseDuration(every)
if err != nil || d <= 0 {
fmt.Fprintf(os.Stderr, "Error: invalid --every duration %q\n", every)
os.Exit(1)
}
schedule["kind"] = "every"
schedule["everyMs"] = d.Milliseconds()
case at != "":
ts, err := time.Parse(time.RFC3339, at)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: invalid --at time %q (use RFC3339, e.g. 2026-07-01T09:00:00Z)\n", at)
os.Exit(1)
}
schedule["kind"] = "at"
schedule["atMs"] = ts.UnixMilli()
default:
fmt.Fprintln(os.Stderr, "Error: one of --cron, --every, or --at is required")
os.Exit(1)
}
var argv []string
switch {
case argvJSON != "":
if err := json.Unmarshal([]byte(argvJSON), &argv); err != nil {
fmt.Fprintf(os.Stderr, "Error: --argv must be a JSON array of strings: %v\n", err)
os.Exit(1)
}
case command != "":
argv = []string{"sh", "-c", command}
default:
fmt.Fprintln(os.Stderr, "Error: one of --command or --argv is required")
os.Exit(1)
}
commandSpec := map[string]any{"argv": argv}
if cwd != "" {
commandSpec["cwd"] = cwd
}
if timeout != "" {
d, err := time.ParseDuration(timeout)
if err != nil || d <= 0 {
fmt.Fprintf(os.Stderr, "Error: invalid --timeout duration %q\n", timeout)
os.Exit(1)
}
commandSpec["timeoutSeconds"] = int(d.Seconds())
}
if len(envPairs) > 0 {
env := map[string]string{}
for _, kv := range envPairs {
k, v, ok := strings.Cut(kv, "=")
if !ok {
fmt.Fprintf(os.Stderr, "Error: --env must be KEY=VALUE, got %q\n", kv)
os.Exit(1)
}
env[k] = v
}
commandSpec["env"] = env
}
cronCreateCommandRPC(name, schedule, commandSpec, deliver, channel, to)
},
}
cmd.Flags().StringVar(&name, "name", "", "job name (lowercase slug, required)")
cmd.Flags().StringVar(&cronExpr, "cron", "", "cron expression (5-field), e.g. '*/15 * * * *'")
cmd.Flags().StringVar(&every, "every", "", "fixed interval as a Go duration, e.g. 15m")
cmd.Flags().StringVar(&at, "at", "", "one-shot time (RFC3339), e.g. 2026-07-01T09:00:00Z")
cmd.Flags().StringVar(&tz, "tz", "", "IANA timezone for --cron (e.g. Asia/Seoul)")
cmd.Flags().StringVar(&command, "command", "", "shell command (run as sh -c)")
cmd.Flags().StringVar(&argvJSON, "argv", "", `explicit argv as a JSON array, e.g. '["node","x.js"]'`)
cmd.Flags().StringVar(&cwd, "cwd", "", "working directory")
cmd.Flags().StringVar(&timeout, "timeout", "", "per-command timeout as a Go duration, e.g. 30s")
cmd.Flags().StringArrayVar(&envPairs, "env", nil, "environment override KEY=VALUE (repeatable)")
cmd.Flags().BoolVar(&deliver, "deliver", false, "deliver command output to a channel")
cmd.Flags().StringVar(&channel, "channel", "", "delivery channel")
cmd.Flags().StringVar(&to, "to", "", "delivery chat/target ID")
return cmd
}
func cronListCmd() *cobra.Command {
var jsonOutput bool
var showDisabled bool
cmd := &cobra.Command{
Use: "list",
Short: "List all cron jobs",
Run: func(cmd *cobra.Command, args []string) {
cronListRPC(showDisabled, jsonOutput)
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
cmd.Flags().BoolVar(&showDisabled, "all", false, "include disabled jobs")
return cmd
}
func cronDeleteCmd() *cobra.Command {
return &cobra.Command{
Use: "delete [jobId]",
Short: "Delete a cron job",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
cronDeleteRPC(args[0])
},
}
}
func cronToggleCmd() *cobra.Command {
return &cobra.Command{
Use: "toggle [jobId] [true|false]",
Short: "Enable or disable a cron job",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
enabled := args[1] == "true" || args[1] == "1" || args[1] == "on"
cronToggleRPC(args[0], enabled)
},
}
}
// --- RPC implementations ---
func cronListRPC(showDisabled, jsonOutput bool) {
requireGateway()
params, _ := json.Marshal(map[string]any{"includeDisabled": showDisabled})
resp, err := gatewayRPC(protocol.MethodCronList, params)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if !resp.OK {
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
os.Exit(1)
}
raw, _ := json.Marshal(resp.Payload)
var result struct {
Jobs []store.CronJob `json:"jobs"`
}
if err := json.Unmarshal(raw, &result); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
printCronJobs(result.Jobs, jsonOutput)
}
func cronCreateCommandRPC(name string, schedule, command map[string]any, deliver bool, channel, to string) {
requireGateway()
params, _ := json.Marshal(map[string]any{
"name": name,
"schedule": schedule,
"command": command,
"deliver": deliver,
"deliverChannel": channel,
"deliverTo": to,
})
resp, err := gatewayRPC(protocol.MethodCronCreate, params)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if !resp.OK {
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
os.Exit(1)
}
raw, _ := json.Marshal(resp.Payload)
var result struct {
Job store.CronJob `json:"job"`
}
if err := json.Unmarshal(raw, &result); err == nil && result.Job.ID != "" {
fmt.Printf("Created command cron job %s (%s)\n", result.Job.ID, result.Job.Name)
return
}
fmt.Println("Created command cron job.")
}
func cronDeleteRPC(jobID string) {
requireGateway()
params, _ := json.Marshal(map[string]string{"jobId": jobID})
resp, err := gatewayRPC(protocol.MethodCronDelete, params)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if !resp.OK {
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
os.Exit(1)
}
fmt.Printf("Deleted job %s\n", jobID)
}
func cronToggleRPC(jobID string, enabled bool) {
requireGateway()
params, _ := json.Marshal(map[string]any{"jobId": jobID, "enabled": enabled})
resp, err := gatewayRPC(protocol.MethodCronToggle, params)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if !resp.OK {
fmt.Fprintf(os.Stderr, "Failed: %s\n", resp.Error.Message)
os.Exit(1)
}
fmt.Printf("Job %s enabled=%v\n", jobID, enabled)
}
// --- Shared display ---
func printCronJobs(jobs []store.CronJob, jsonOutput bool) {
if jsonOutput {
data, _ := json.MarshalIndent(jobs, "", " ")
fmt.Println(string(data))
return
}
if len(jobs) == 0 {
fmt.Println("No cron jobs configured.")
return
}
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintf(tw, "ID\tNAME\tENABLED\tSCHEDULE\tLAST RUN\n")
for _, j := range jobs {
schedule := j.Schedule.Kind
if j.Schedule.Expr != "" {
schedule = j.Schedule.Expr
} else if j.Schedule.EveryMS != nil {
d := time.Duration(*j.Schedule.EveryMS) * time.Millisecond
schedule = "every " + d.String()
}
lastRun := "never"
if j.State.LastRunAtMS != nil {
lastRun = time.UnixMilli(*j.State.LastRunAtMS).Format(time.DateTime)
}
idShort := j.ID
if len(idShort) > 8 {
idShort = idShort[:8]
}
fmt.Fprintf(tw, "%s\t%s\t%v\t%s\t%s\n",
idShort, j.Name, j.Enabled, schedule, lastRun)
}
tw.Flush()
}