mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 18:16:09 +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>
143 lines
4.3 KiB
Go
143 lines
4.3 KiB
Go
//go:build !windows
|
|
|
|
package cronexec
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRun_StdoutOK(t *testing.T) {
|
|
res := Run(context.Background(), Spec{Argv: []string{"sh", "-c", "printf 'hello world'"}})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok (err=%v)", res.Status, res.Err)
|
|
}
|
|
if res.ExitCode != 0 {
|
|
t.Errorf("exit code = %d, want 0", res.ExitCode)
|
|
}
|
|
if res.Summary != "hello world" {
|
|
t.Errorf("summary = %q, want %q", res.Summary, "hello world")
|
|
}
|
|
}
|
|
|
|
func TestRun_NonZeroExitIsError(t *testing.T) {
|
|
res := Run(context.Background(), Spec{Argv: []string{"sh", "-c", "echo oops 1>&2; exit 7"}})
|
|
if res.Status != StatusError {
|
|
t.Fatalf("status = %q, want error", res.Status)
|
|
}
|
|
if res.ExitCode != 7 {
|
|
t.Errorf("exit code = %d, want 7", res.ExitCode)
|
|
}
|
|
if res.Err == nil || !strings.Contains(res.Err.Error(), "code 7") {
|
|
t.Errorf("err = %v, want it to mention exit code 7", res.Err)
|
|
}
|
|
if !strings.Contains(res.Err.Error(), "oops") {
|
|
t.Errorf("err = %v, want it to include stderr (oops)", res.Err)
|
|
}
|
|
if !strings.Contains(res.Summary, "oops") {
|
|
t.Errorf("summary = %q, want it to include stderr", res.Summary)
|
|
}
|
|
}
|
|
|
|
func TestRun_Timeout(t *testing.T) {
|
|
start := time.Now()
|
|
res := Run(context.Background(), Spec{
|
|
Argv: []string{"sh", "-c", "sleep 10"},
|
|
Timeout: 200 * time.Millisecond,
|
|
})
|
|
if res.Status != StatusError {
|
|
t.Fatalf("status = %q, want error", res.Status)
|
|
}
|
|
if res.Termination != TermTimeout {
|
|
t.Errorf("termination = %q, want %q", res.Termination, TermTimeout)
|
|
}
|
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
|
t.Errorf("took %s, expected the watchdog to kill it promptly", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestRun_NoOutputTimeout(t *testing.T) {
|
|
res := Run(context.Background(), Spec{
|
|
Argv: []string{"sh", "-c", "sleep 10"},
|
|
NoOutputTimeout: 200 * time.Millisecond,
|
|
})
|
|
if res.Status != StatusError {
|
|
t.Fatalf("status = %q, want error", res.Status)
|
|
}
|
|
if res.Termination != TermNoOutputTimeout {
|
|
t.Errorf("termination = %q, want %q", res.Termination, TermNoOutputTimeout)
|
|
}
|
|
}
|
|
|
|
func TestRun_NoOutputTimeoutNotTrippedWhenProducingOutput(t *testing.T) {
|
|
// Emits a line every 50ms for ~300ms, then exits 0. A 200ms no-output
|
|
// timeout must NOT fire because output keeps arriving.
|
|
res := Run(context.Background(), Spec{
|
|
Argv: []string{"sh", "-c", "for i in 1 2 3 4 5 6; do echo tick; sleep 0.05; done"},
|
|
NoOutputTimeout: 200 * time.Millisecond,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok (term=%q err=%v)", res.Status, res.Termination, res.Err)
|
|
}
|
|
}
|
|
|
|
func TestRun_Stdin(t *testing.T) {
|
|
res := Run(context.Background(), Spec{
|
|
Argv: []string{"sh", "-c", "cat"},
|
|
Input: "piped-input",
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok", res.Status)
|
|
}
|
|
if res.Summary != "piped-input" {
|
|
t.Errorf("summary = %q, want %q", res.Summary, "piped-input")
|
|
}
|
|
}
|
|
|
|
func TestRun_Env(t *testing.T) {
|
|
res := Run(context.Background(), Spec{
|
|
Argv: []string{"sh", "-c", "printf '%s' \"$CRON_TEST_VAR\""},
|
|
Env: map[string]string{"CRON_TEST_VAR": "from-env"},
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok", res.Status)
|
|
}
|
|
if res.Summary != "from-env" {
|
|
t.Errorf("summary = %q, want %q", res.Summary, "from-env")
|
|
}
|
|
}
|
|
|
|
func TestRun_OutputCap(t *testing.T) {
|
|
res := Run(context.Background(), Spec{
|
|
Argv: []string{"sh", "-c", "yes x | head -c 100000"},
|
|
OutputMaxBytes: 1024,
|
|
Timeout: 5 * time.Second,
|
|
})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok (err=%v)", res.Status, res.Err)
|
|
}
|
|
if len(res.Stdout) > 1024 {
|
|
t.Errorf("stdout length = %d, want capped at 1024", len(res.Stdout))
|
|
}
|
|
}
|
|
|
|
func TestRun_EmptyArgv(t *testing.T) {
|
|
res := Run(context.Background(), Spec{})
|
|
if res.Status != StatusError || res.Err == nil {
|
|
t.Fatalf("empty argv should error, got status=%q err=%v", res.Status, res.Err)
|
|
}
|
|
}
|
|
|
|
func TestRun_StdoutPreferredOverStderr(t *testing.T) {
|
|
res := Run(context.Background(), Spec{Argv: []string{"sh", "-c", "echo out; echo err 1>&2"}})
|
|
if res.Status != StatusOK {
|
|
t.Fatalf("status = %q, want ok", res.Status)
|
|
}
|
|
if !strings.Contains(res.Summary, "stdout:") || !strings.Contains(res.Summary, "stderr:") {
|
|
t.Errorf("summary should label both streams, got %q", res.Summary)
|
|
}
|
|
}
|