mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-03 09:13:30 +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>
105 lines
3.4 KiB
Go
105 lines
3.4 KiB
Go
//go:build !windows
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func commandCronConfig(enabled bool) *config.Config {
|
|
c := &config.Config{}
|
|
c.Cron.CommandEnabled = enabled
|
|
return c
|
|
}
|
|
|
|
func commandCronJob(spec *store.CronCommandSpec, deliver bool) *store.CronJob {
|
|
job := &store.CronJob{
|
|
ID: uuid.NewString(),
|
|
TenantID: uuid.New(),
|
|
Name: "probe",
|
|
AgentID: "ops",
|
|
UserID: "user-1",
|
|
Payload: store.CronPayload{Kind: store.CronPayloadKindCommand, Command: spec},
|
|
}
|
|
if deliver {
|
|
job.Deliver = true
|
|
job.DeliverChannel = "telegram"
|
|
job.DeliverTo = "chat-1"
|
|
}
|
|
return job
|
|
}
|
|
|
|
// A command payload must be refused unless cron.command_enabled is set.
|
|
func TestCronJobHandler_CommandDisabled(t *testing.T) {
|
|
handler := makeCronJobHandler(nil, nil, commandCronConfig(false), nil, nil, nil, nil, nil)
|
|
if _, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "echo hi"}}, false)); err == nil {
|
|
t.Fatal("expected error when cron.command_enabled is false")
|
|
}
|
|
}
|
|
|
|
// A successful command runs with zero model tokens and its stdout is delivered.
|
|
func TestCronJobHandler_CommandSuccessDelivers(t *testing.T) {
|
|
mb := bus.New()
|
|
defer mb.Close()
|
|
|
|
handler := makeCronJobHandler(nil, mb, commandCronConfig(true), nil, nil, nil, nil, nil)
|
|
result, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "printf hello"}}, true))
|
|
if err != nil {
|
|
t.Fatalf("command cron returned error: %v", err)
|
|
}
|
|
if result == nil || result.Content != "hello" {
|
|
t.Fatalf("result = %#v, want content hello", result)
|
|
}
|
|
if result.InputTokens != 0 || result.OutputTokens != 0 {
|
|
t.Errorf("command cron must report zero tokens, got in=%d out=%d", result.InputTokens, result.OutputTokens)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
got, ok := mb.SubscribeOutbound(ctx)
|
|
if !ok {
|
|
t.Fatal("expected outbound delivery of command output")
|
|
}
|
|
if got.Content != "hello" || got.Channel != "telegram" || got.ChatID != "chat-1" {
|
|
t.Fatalf("outbound = %#v, want telegram/chat-1/hello", got)
|
|
}
|
|
}
|
|
|
|
// A non-zero exit returns an error (recorded as a failed run) and is NOT
|
|
// delivered — only successful output is announced.
|
|
func TestCronJobHandler_CommandFailureNotDelivered(t *testing.T) {
|
|
mb := bus.New()
|
|
defer mb.Close()
|
|
|
|
handler := makeCronJobHandler(nil, mb, commandCronConfig(true), nil, nil, nil, nil, nil)
|
|
result, err := handler(commandCronJob(&store.CronCommandSpec{Argv: []string{"sh", "-c", "echo boom 1>&2; exit 3"}}, true))
|
|
if err == nil {
|
|
t.Fatal("expected error for non-zero command exit")
|
|
}
|
|
if result != nil {
|
|
t.Fatalf("failed command should return nil result, got %#v", result)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
|
defer cancel()
|
|
if got, ok := mb.SubscribeOutbound(ctx); ok {
|
|
t.Fatalf("failed command must not deliver, got %#v", got)
|
|
}
|
|
}
|
|
|
|
// An empty argv is rejected before execution.
|
|
func TestCronJobHandler_CommandInvalidSpec(t *testing.T) {
|
|
handler := makeCronJobHandler(nil, nil, commandCronConfig(true), nil, nil, nil, nil, nil)
|
|
if _, err := handler(commandCronJob(&store.CronCommandSpec{}, false)); err == nil {
|
|
t.Fatal("expected error for empty argv")
|
|
}
|
|
}
|