package tools import ( "context" "encoding/json" "fmt" "time" "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/store" ) // CronTool lets agents manage Gateway cron jobs. // Matching OpenClaw src/agents/tools/cron-tool.ts. type CronTool struct { cronStore store.CronStore permStore store.ConfigPermissionStore // nil = no group restriction providerStore store.ProviderStore // nil = provider override by name unavailable commandEnabled bool // allow deterministic command payloads (mirrors cron.command_enabled) } func NewCronTool(cronStore store.CronStore) *CronTool { return &CronTool{cronStore: cronStore} } // SetConfigPermStore enables group cron mutation restriction. func (t *CronTool) SetConfigPermStore(s store.ConfigPermissionStore) { t.permStore = s } // SetProviderStore enables resolving per-job LLM provider overrides by name. func (t *CronTool) SetProviderStore(s store.ProviderStore) { t.providerStore = s } // SetCommandEnabled allows this tool to create deterministic command-payload // cron jobs (kind="command", no LLM). Mirrors the gateway's cron.command_enabled. func (t *CronTool) SetCommandEnabled(enabled bool) { t.commandEnabled = enabled } func (t *CronTool) Name() string { return "cron" } func (t *CronTool) Description() string { return `Manage Gateway cron jobs. Always send a JSON object with an "action" field. VALID ACTIONS AND EXACT PAYLOAD SHAPES: 1) status { "action": "status" } 2) list { "action": "list", "includeDisabled": true|false } 3) add { "action": "add", "job": { "name": "string", // required, lowercase slug: [a-z0-9-]+ "schedule": { ... }, // required "message": "string", // required "deliver": true|false, // optional, default false "channel": "string", // optional, auto-filled from current channel context "to": "string", // optional "agentId": "string", // optional, defaults to current agent "provider": "string", // optional, LLM provider NAME (e.g. "groq") to run this job on a cheaper model; unset → agent default "model": "string", // optional, model id for the override provider "deleteAfterRun": true|false // optional, default true for schedule.kind="at" } } 4) update { "action": "update", "jobId": "string", "patch": { "name": "string", "schedule": { ... }, "message": "string", "deliver": true|false, "channel": "string", "to": "string", "agentId": "string", "provider": "string", "model": "string", "deleteAfterRun": true|false, "disabled": true|false } } 5) remove { "action": "remove", "jobId": "string" } 6) run { "action": "run", "jobId": "string" } 7) runs { "action": "runs", "jobId": "string" } SCHEDULE SCHEMA: - at: { "kind": "at", "atMs": } - every: { "kind": "every", "everyMs": } - cron: { "kind": "cron", "expr": "<5-field cron>", "tz": "" } RULES: - For action="add", send the job inside "job". Do not place job fields at the root level. - For action="update", send changes inside "patch". Do not place patch fields at the root level. - Always use "jobId". Do not use "id". - "name", "schedule", and "message" are required for add. - "name" must match: lowercase letters, numbers, hyphens only. - Before creating or updating a scheduled job, call the datetime tool first to get the precise current time and unix_ms timestamp. Never guess timestamps. - Omit optional fields when unknown; do not invent placeholder values like "", 0, or null unless required. - Jobs run as isolated agent turns using the provided "message". DETERMINISTIC COMMAND JOBS (no LLM, zero tokens): - Instead of "message", set "command" to a shell string (run as sh -c) OR "commandArgv" to an explicit argv array (no shell parsing). - Optional: "commandCwd", "commandEnv" {"KEY":"VAL"}, "commandTimeoutSeconds". - The command runs inside the gateway process. Only available when the gateway has cron.command_enabled=true; otherwise add returns an error. - Use for scheduled probes/scripts that don't need the model. Output is delivered like a normal job when "deliver" is set; a non-zero exit records the run as an error.` } func (t *CronTool) Parameters() map[string]any { return map[string]any{ "type": "object", "properties": map[string]any{ "action": map[string]any{ "type": "string", "description": "The cron action to perform", "enum": []string{"status", "list", "add", "update", "remove", "run", "runs"}, }, "includeDisabled": map[string]any{ "type": "boolean", "description": "Include disabled jobs in list (default false)", }, "job": map[string]any{ "type": "object", "description": "Job definition for add action (name, schedule, message, deliver, channel, to, agentId, provider, model, deleteAfterRun)", "additionalProperties": true, }, "jobId": map[string]any{ "type": "string", "description": "Job ID for update/remove/run/runs actions", }, "id": map[string]any{ "type": "string", "description": "Backward compatibility alias for jobId", }, "patch": map[string]any{ "type": "object", "description": "Patch object for update action", "additionalProperties": true, }, "runMode": map[string]any{ "type": "string", "description": "Run mode: 'due' (only if due) or 'force' (immediate)", "enum": []string{"due", "force"}, }, }, "required": []string{"action"}, } } func (t *CronTool) Execute(ctx context.Context, args map[string]any) *Result { action, _ := args["action"].(string) if action == "" { return ErrorResult("action parameter is required") } // Group cron permission check for mutation actions if t.permStore != nil && (action == "add" || action == "update" || action == "remove") { if err := store.CheckCronPermission(ctx, t.permStore); err != nil { return ErrorResult("permission denied: only users with cron or file_writer permission can manage cron jobs in group chats") } } agentID := resolveAgentIDString(ctx) // SCOPE-intentional (#915 audit 2026-04-16): cron jobs follow the per-group // memory model — all group members share the same cron_jobs rows via the // group-scope user_id. Migrating to ActorIDFromContext would split jobs // per-individual and break collaborative /cron list/add/remove UX. userID := store.UserIDFromContext(ctx) switch action { case "status": return t.handleStatus() case "list": return t.handleList(ctx, args, agentID, userID) case "add": return t.handleAdd(ctx, args, agentID, userID) case "update": return t.handleUpdate(ctx, args, agentID, userID) case "remove": return t.handleRemove(ctx, args, agentID, userID) case "run": return t.handleRun(ctx, args, agentID, userID) case "runs": return t.handleRuns(ctx, args, agentID, userID) default: return ErrorResult(fmt.Sprintf("unknown action: %s", action)) } } func (t *CronTool) handleStatus() *Result { status := t.cronStore.Status() data, _ := json.MarshalIndent(status, "", " ") return NewResult(string(data)) } func (t *CronTool) handleList(ctx context.Context, args map[string]any, agentID, userID string) *Result { includeDisabled, _ := args["includeDisabled"].(bool) jobs := t.cronStore.ListJobs(ctx, includeDisabled, agentID, userID) jobs = store.RedactCronJobsCredentialContext(jobs) result := map[string]any{ "jobs": jobs, "count": len(jobs), } data, _ := json.MarshalIndent(result, "", " ") return NewResult(string(data)) } func (t *CronTool) handleAdd(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobObj, ok := args["job"].(map[string]any) if !ok { return ErrorResult("job object is required for add action") } name, _ := jobObj["name"].(string) if name == "" { return ErrorResult("job.name is required") } scheduleObj, ok := jobObj["schedule"].(map[string]any) if !ok { return ErrorResult("job.schedule is required") } // Optional deterministic command payload (runs a shell command, no LLM turn). cmdSpec := parseCronCommandSpec(jobObj) if cmdSpec != nil { if !t.commandEnabled { return ErrorResult("command cron is disabled on this gateway (set cron.command_enabled=true to allow it)") } if err := store.ValidateCronCommandSpec(cmdSpec); err != nil { return ErrorResult(err.Error()) } } message, _ := jobObj["message"].(string) if cmdSpec == nil && message == "" { return ErrorResult("job.message is required (or set job.command/job.commandArgv for a deterministic command job)") } // Parse schedule schedule := store.CronSchedule{ Kind: stringFromMap(scheduleObj, "kind"), } if schedule.Kind == "" { return ErrorResult("job.schedule.kind is required (at, every, or cron)") } switch schedule.Kind { case "at": if v, ok := numberFromMap(scheduleObj, "atMs"); ok { ms := int64(v) if ms <= time.Now().UnixMilli() { return ErrorResult(fmt.Sprintf("job.schedule.atMs is in the past (%d). Use a future Unix timestamp in milliseconds. Current time is %d ms", ms, time.Now().UnixMilli())) } schedule.AtMS = &ms } else { return ErrorResult("job.schedule.atMs is required for 'at' schedule") } case "every": if v, ok := numberFromMap(scheduleObj, "everyMs"); ok { ms := int64(v) schedule.EveryMS = &ms } else { return ErrorResult("job.schedule.everyMs is required for 'every' schedule") } case "cron": schedule.Expr = stringFromMap(scheduleObj, "expr") if schedule.Expr == "" { return ErrorResult("job.schedule.expr is required for 'cron' schedule") } schedule.TZ = stringFromMap(scheduleObj, "tz") if schedule.TZ != "" { if _, err := time.LoadLocation(schedule.TZ); err != nil { return ErrorResult(fmt.Sprintf("invalid timezone '%s': use IANA names like 'Asia/Ho_Chi_Minh', 'America/New_York'", schedule.TZ)) } } default: return ErrorResult(fmt.Sprintf("invalid schedule kind: %s (must be at, every, or cron)", schedule.Kind)) } // Optional fields deliver, _ := jobObj["deliver"].(bool) channel, _ := jobObj["channel"].(string) to, _ := jobObj["to"].(string) // Auto-default deliver=true when the request comes from a real channel // (not CLI/system/subagent). Users chatting on Zalo/Telegram expect // cron results delivered back to the same chat. if !deliver { if ctxChannel := ToolChannelFromCtx(ctx); ctxChannel != "" { switch ctxChannel { case "cli", "system", "subagent", "cron", "teammate": // internal channels — don't auto-deliver default: deliver = true } } } // Auto-fill channel and to from context when deliver is requested. // Always prefer context values over LLM-provided values to prevent // misrouted deliveries (e.g. LLM confusing guild ID with channel ID). if deliver { if ctxChannel := ToolChannelFromCtx(ctx); ctxChannel != "" { channel = ctxChannel } if ctxChatID := ToolChatIDFromCtx(ctx); ctxChatID != "" { to = ctxChatID } } // Use agent ID from job object if explicitly provided, otherwise from context if explicit, _ := jobObj["agentId"].(string); explicit != "" { agentID = explicit } // Resolve optional per-job provider/model override before creating the job. providerID, errR := t.resolveProviderID(ctx, jobObj) if errR != nil { return errR } modelOverride, _ := jobObj["model"].(string) job, err := t.cronStore.AddJob(ctx, name, schedule, message, deliver, channel, to, agentID, userID) if err != nil { return ErrorResult(fmt.Sprintf("failed to create cron job: %v", err)) } // Apply post-create overrides (wake_heartbeat, provider/model) in a single patch. overridePatch := store.CronJobPatch{} needOverride := false if wh, _ := jobObj["wake_heartbeat"].(bool); wh { wakeTrue := true overridePatch.WakeHeartbeat = &wakeTrue needOverride = true } if providerID != nil { overridePatch.ProviderID = providerID needOverride = true } if modelOverride != "" { overridePatch.Model = &modelOverride needOverride = true } if cmdSpec != nil { overridePatch.Command = cmdSpec needOverride = true } if needOverride { if updated, uErr := t.cronStore.UpdateJob(ctx, job.ID, overridePatch); uErr == nil { job = updated } } data, _ := json.MarshalIndent(map[string]any{"job": store.RedactCronJobCredentialContext(*job)}, "", " ") return NewResult(string(data)) } // checkJobOwnership validates that the job belongs to the current agent+user scope. // When agentID/userID is empty, all jobs are accessible. func (t *CronTool) checkJobOwnership(ctx context.Context, jobID, agentID, userID string) (*store.CronJob, *Result) { job, ok := t.cronStore.GetJob(ctx, jobID) if !ok { return nil, ErrorResult(fmt.Sprintf("job %s not found", jobID)) } // Verify ownership if agentID != "" && job.AgentID != agentID { return nil, ErrorResult(fmt.Sprintf("job %s not found", jobID)) } if userID != "" && job.UserID != userID { return nil, ErrorResult(fmt.Sprintf("job %s not found", jobID)) } return job, nil } func (t *CronTool) handleUpdate(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for update action") } existing, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID) if errResult != nil { return errResult } if err := store.CheckCronCredentialOwner(ctx, existing); err != nil { return ErrorResult("permission denied: cron job uses a credential context owned by another user") } patchObj, ok := args["patch"].(map[string]any) if !ok { return ErrorResult("patch object is required for update action") } // A command payload on update follows the same forms (shell string or argv) // and the same gate as add. Parse it out first and drop the raw keys so the // generic patch unmarshal below — whose Command field is a structured spec — // can't choke on a shell string, and so update can't slip a command payload // past the command-enabled gate. cmdSpec := parseCronCommandSpec(patchObj) delete(patchObj, "command") delete(patchObj, "commandArgv") var patch store.CronJobPatch // Re-marshal and unmarshal to leverage JSON tags patchJSON, _ := json.Marshal(patchObj) json.Unmarshal(patchJSON, &patch) if cmdSpec != nil { if !t.commandEnabled { return ErrorResult("command cron is disabled on this gateway (set cron.command_enabled=true to allow it)") } if err := store.ValidateCronCommandSpec(cmdSpec); err != nil { return ErrorResult(err.Error()) } patch.Command = cmdSpec } // Resolve provider override by name (providerId UUID is handled by JSON tags above). if name, _ := patchObj["provider"].(string); name != "" { pid, errR := t.resolveProviderID(ctx, patchObj) if errR != nil { return errR } patch.ProviderID = pid } // Validate atMs not in the past when updating schedule if patch.Schedule != nil && patch.Schedule.Kind == "at" && patch.Schedule.AtMS != nil { if *patch.Schedule.AtMS <= time.Now().UnixMilli() { return ErrorResult(fmt.Sprintf("schedule.atMs is in the past (%d). Use the datetime tool to get current time, then set a future timestamp. Current time is %d ms", *patch.Schedule.AtMS, time.Now().UnixMilli())) } } job, err := t.cronStore.UpdateJob(ctx, jobID, patch) if err != nil { return ErrorResult(fmt.Sprintf("failed to update cron job: %v", err)) } data, _ := json.MarshalIndent(map[string]any{"job": store.RedactCronJobCredentialContext(*job)}, "", " ") return NewResult(string(data)) } func (t *CronTool) handleRemove(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for remove action") } _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID) if errResult != nil { return errResult } if err := t.cronStore.RemoveJob(ctx, jobID); err != nil { return ErrorResult(fmt.Sprintf("failed to remove cron job: %v", err)) } data, _ := json.MarshalIndent(map[string]any{"deleted": true, "jobId": jobID}, "", " ") return NewResult(string(data)) } func (t *CronTool) handleRun(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) if jobID == "" { return ErrorResult("jobId is required for run action") } existing, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID) if errResult != nil { return errResult } if err := store.CheckCronCredentialOwner(ctx, existing); err != nil { return ErrorResult("permission denied: cron job uses a credential context owned by another user") } runMode, _ := args["runMode"].(string) force := runMode == "force" ran, reason, err := t.cronStore.RunJob(ctx, jobID, force) if err != nil { return ErrorResult(fmt.Sprintf("failed to run cron job: %v", err)) } result := map[string]any{ "ran": ran, "jobId": jobID, } if !ran && reason != "" { result["reason"] = reason } data, _ := json.MarshalIndent(result, "", " ") return NewResult(string(data)) } func (t *CronTool) handleRuns(ctx context.Context, args map[string]any, agentID, userID string) *Result { jobID := resolveJobID(args) // Validate ownership if a specific job is requested if jobID != "" { if _, errResult := t.checkJobOwnership(ctx, jobID, agentID, userID); errResult != nil { return errResult } } limit := 20 if v, ok := numberFromMap(args, "limit"); ok { limit = int(v) } entries, total := t.cronStore.GetRunLog(ctx, jobID, limit, 0) result := map[string]any{ "entries": entries, "count": len(entries), "total": total, } data, _ := json.MarshalIndent(result, "", " ") return NewResult(string(data)) } // resolveProviderID resolves an optional per-job provider override from a job/patch // map. Accepts "providerId" (UUID string) or "provider" (provider name, looked up // via the provider store). Returns (nil, nil) when neither is present. func (t *CronTool) resolveProviderID(ctx context.Context, m map[string]any) (*uuid.UUID, *Result) { if raw, _ := m["providerId"].(string); raw != "" { id, err := uuid.Parse(raw) if err != nil { return nil, ErrorResult(fmt.Sprintf("invalid providerId %q: must be a UUID", raw)) } return &id, nil } if name, _ := m["provider"].(string); name != "" { if t.providerStore == nil { return nil, ErrorResult("provider override by name is unavailable; pass providerId instead") } pd, err := t.providerStore.GetProviderByName(ctx, name) if err != nil || pd == nil { return nil, ErrorResult(fmt.Sprintf("provider %q not found; register it first or pass a valid providerId", name)) } return &pd.ID, nil } return nil, nil } // --- helpers --- func resolveJobID(args map[string]any) string { if id, ok := args["jobId"].(string); ok && id != "" { return id } if id, ok := args["id"].(string); ok && id != "" { return id } return "" } func stringFromMap(m map[string]any, key string) string { v, _ := m[key].(string) return v } func numberFromMap(m map[string]any, key string) (float64, bool) { v, ok := m[key].(float64) return v, ok } // parseCronCommandSpec extracts an optional deterministic command payload from a // cron tool "job" object. It accepts either a shell string ("command", wrapped // as ["sh","-c",...]) or an explicit argv array ("commandArgv"). Returns nil // when no command fields are present (i.e. it's a normal agent-turn job). func parseCronCommandSpec(jobObj map[string]any) *store.CronCommandSpec { var argv []string if raw, ok := jobObj["commandArgv"].([]any); ok { for _, v := range raw { if s, ok := v.(string); ok { argv = append(argv, s) } } } if len(argv) == 0 { if s, _ := jobObj["command"].(string); s != "" { argv = []string{"sh", "-c", s} } } if len(argv) == 0 { return nil } spec := &store.CronCommandSpec{Argv: argv} if cwd, _ := jobObj["commandCwd"].(string); cwd != "" { spec.Cwd = cwd } if v, ok := numberFromMap(jobObj, "commandTimeoutSeconds"); ok { spec.TimeoutSeconds = int(v) } if v, ok := numberFromMap(jobObj, "commandNoOutputTimeoutSeconds"); ok { spec.NoOutputTimeoutSeconds = int(v) } if v, ok := numberFromMap(jobObj, "commandOutputMaxBytes"); ok { spec.OutputMaxBytes = int(v) } if env, ok := jobObj["commandEnv"].(map[string]any); ok && len(env) > 0 { spec.Env = make(map[string]string, len(env)) for k, v := range env { if s, ok := v.(string); ok { spec.Env[k] = s } } } return spec }