Merge pull request #3137 from jp39/cron-command-remotes
feat: allow configured remote cron commands
This commit is contained in:
commit
a16a1e1535
9 changed files with 617 additions and 45 deletions
|
|
@ -380,7 +380,9 @@
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5,
|
||||||
|
"allow_command": true,
|
||||||
|
"command_allowed_remotes": []
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|
|
||||||
|
|
@ -1154,7 +1154,9 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
|
||||||
"tools": {
|
"tools": {
|
||||||
"cron": {
|
"cron": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5,
|
||||||
|
"allow_command": true,
|
||||||
|
"command_allowed_remotes": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1162,6 +1164,8 @@ PicoClaw supports cron-style scheduled tasks via the `cron` tool. The agent can
|
||||||
|
|
||||||
Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
|
Scheduled tasks persist across restarts and are stored in `~/.picoclaw/workspace/cron/`.
|
||||||
|
|
||||||
|
Command cron jobs can execute shell commands. By default, remote channels cannot schedule command jobs. To allow specific remote channels, set `command_allowed_remotes` to entries such as `"telegram"` or `"telegram:1234567890"`; use `"*"` only if every non-empty channel should be allowed. The `"*"` wildcard is potentially dangerous because any remote channel that can talk to PicoClaw can schedule shell commands. This does not bypass `allow_command`, `command_confirm`, or exec safety checks.
|
||||||
|
|
||||||
### Advanced Topics
|
### Advanced Topics
|
||||||
|
|
||||||
| Topic | Description |
|
| Topic | Description |
|
||||||
|
|
|
||||||
|
|
@ -647,7 +647,9 @@ picoclaw agent -m "Hello"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5,
|
||||||
|
"allow_command": true,
|
||||||
|
"command_allowed_remotes": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"heartbeat": {
|
"heartbeat": {
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ the original prompt, delivery target, or command payload.
|
||||||
Remote channel access is scoped to the current `channel/chat_id`: remote callers
|
Remote channel access is scoped to the current `channel/chat_id`: remote callers
|
||||||
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
|
can only list, get, or update jobs whose saved `payload.channel` and `payload.to`
|
||||||
match the current conversation. Command jobs include a shell command payload, so
|
match the current conversation. Command jobs include a shell command payload, so
|
||||||
they can only be listed, inspected, or updated from internal channels.
|
they can only be listed, inspected, or updated from internal channels or remote
|
||||||
|
channels allowed by `tools.cron.command_allowed_remotes`.
|
||||||
|
|
||||||
Example tool calls:
|
Example tool calls:
|
||||||
|
|
||||||
|
|
@ -59,7 +60,7 @@ Example tool calls:
|
||||||
(`at_seconds`, `every_seconds`, or `cron_expr`).
|
(`at_seconds`, `every_seconds`, or `cron_expr`).
|
||||||
Omit `command` to preserve it, set `command` to a non-empty string to replace
|
Omit `command` to preserve it, set `command` to a non-empty string to replace
|
||||||
it, or set `command` to `""` to clear it. Command updates require the same
|
it, or set `command` to `""` to clear it. Command updates require the same
|
||||||
internal channel and confirmation gates as command creation.
|
channel allowlist and confirmation gates as command creation.
|
||||||
|
|
||||||
## Execution Modes
|
## Execution Modes
|
||||||
|
|
||||||
|
|
@ -104,7 +105,7 @@ If `tools.exec.enabled` is `false`:
|
||||||
- new command jobs are rejected by the cron tool
|
- new command jobs are rejected by the cron tool
|
||||||
- existing command jobs publish a `command execution is disabled` error when they fire
|
- existing command jobs publish a `command execution is disabled` error when they fire
|
||||||
|
|
||||||
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling already requires an internal channel when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels.
|
`tools.exec.allow_remote` is still enforced by the exec tool, but cron command scheduling has its own channel gate when the job is created. In practice, reminder jobs can be scheduled from remote channels, while scheduled command jobs are limited to internal channels and configured remote channels.
|
||||||
|
|
||||||
### `allow_command`
|
### `allow_command`
|
||||||
|
|
||||||
|
|
@ -112,7 +113,23 @@ If `tools.exec.enabled` is `false`:
|
||||||
|
|
||||||
This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`.
|
This is not a hard disable switch. If you set `allow_command` to `false`, PicoClaw still allows a command job when the caller explicitly passes `command_confirm: true`.
|
||||||
|
|
||||||
Command jobs also require an internal channel. Non-command reminders do not have that restriction.
|
Command jobs also require either an internal channel or a remote channel allowed by `tools.cron.command_allowed_remotes`. Non-command reminders do not have that restriction.
|
||||||
|
|
||||||
|
### `command_allowed_remotes`
|
||||||
|
|
||||||
|
`tools.cron.command_allowed_remotes` defaults to an empty list. With the default empty list, remote channels cannot schedule command jobs.
|
||||||
|
|
||||||
|
Entries can be either a channel name or a channel plus chat id:
|
||||||
|
|
||||||
|
- `telegram` allows command jobs from any Telegram chat.
|
||||||
|
- `telegram:1234567890` allows command jobs only from that exact Telegram chat id.
|
||||||
|
- `*` allows command jobs from every non-empty channel.
|
||||||
|
|
||||||
|
Warning: `*` is potentially dangerous because any remote channel that can talk
|
||||||
|
to PicoClaw can schedule shell commands. Use it only when every enabled remote
|
||||||
|
channel and chat is trusted to request command execution.
|
||||||
|
|
||||||
|
This setting only controls the remote-channel gate. It does not bypass `tools.cron.allow_command`, `command_confirm`, `tools.exec.enabled`, or the exec tool's command safety checks.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|
@ -122,7 +139,10 @@ Example:
|
||||||
"cron": {
|
"cron": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"exec_timeout_minutes": 5,
|
"exec_timeout_minutes": 5,
|
||||||
"allow_command": true
|
"allow_command": true,
|
||||||
|
"command_allowed_remotes": [
|
||||||
|
"telegram:1234567890"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"exec": {
|
"exec": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
|
|
|
||||||
|
|
@ -312,9 +312,10 @@ as containers, VMs, or an approval flow around build-and-run commands.
|
||||||
The cron tool is used for scheduling periodic tasks.
|
The cron tool is used for scheduling periodic tasks.
|
||||||
|
|
||||||
| Config | Type | Default | Description |
|
| Config | Type | Default | Description |
|
||||||
|------------------------|------|---------|------------------------------------------------|
|
|---------------------------|----------|---------|----------------------------------------------------------------|
|
||||||
| `enabled` | bool | true | Register the agent-facing cron tool |
|
| `enabled` | bool | true | Register the agent-facing cron tool |
|
||||||
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
|
| `allow_command` | bool | true | Allow command jobs without extra confirmation |
|
||||||
|
| `command_allowed_remotes` | string[] | [] | Remote channels or `channel:chat_id` values allowed for command jobs; `*` allows every channel and is dangerous unless all remote channels are trusted |
|
||||||
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
|
||||||
|
|
||||||
For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md).
|
For schedule types, execution modes (`deliver`, agent turn, and command jobs), persistence, and the current command-security gates, see [Scheduled Tasks and Cron Jobs](cron.md).
|
||||||
|
|
|
||||||
|
|
@ -1028,9 +1028,11 @@ type WebToolsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type CronToolsConfig struct {
|
type CronToolsConfig struct {
|
||||||
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
ToolConfig `envPrefix:"PICOCLAW_TOOLS_CRON_"`
|
||||||
ExecTimeoutMinutes int ` json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
|
// 0 means no timeout.
|
||||||
AllowCommand bool ` json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
|
ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"`
|
||||||
|
AllowCommand bool `json:"allow_command" env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND"`
|
||||||
|
CommandAllowedRemotes []string `json:"command_allowed_remotes" env:"PICOCLAW_TOOLS_CRON_COMMAND_ALLOWED_REMOTES"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExecConfig struct {
|
type ExecConfig struct {
|
||||||
|
|
|
||||||
|
|
@ -1540,6 +1540,16 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultConfig_CronCommandAllowedRemotesEmpty(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
if len(cfg.Tools.Cron.CommandAllowedRemotes) != 0 {
|
||||||
|
t.Fatalf(
|
||||||
|
"DefaultConfig().Tools.Cron.CommandAllowedRemotes = %#v, want empty",
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultConfig_HooksDefaults(t *testing.T) {
|
func TestDefaultConfig_HooksDefaults(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
if !cfg.Hooks.Enabled {
|
if !cfg.Hooks.Enabled {
|
||||||
|
|
@ -1600,6 +1610,32 @@ func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_CronCommandAllowedRemotes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
if err := os.WriteFile(
|
||||||
|
configPath,
|
||||||
|
[]byte(`{"version":1,"tools":{"cron":{"command_allowed_remotes":["telegram:1234567890","discord"]}}}`),
|
||||||
|
0o600,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error: %v", err)
|
||||||
|
}
|
||||||
|
want := []string{"telegram:1234567890", "discord"}
|
||||||
|
if len(cfg.Tools.Cron.CommandAllowedRemotes) != len(want) {
|
||||||
|
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if cfg.Tools.Cron.CommandAllowedRemotes[i] != want[i] {
|
||||||
|
t.Fatalf("CommandAllowedRemotes = %#v, want %#v", cfg.Tools.Cron.CommandAllowedRemotes, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
configPath := filepath.Join(tmpDir, "config.json")
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ type CronTool struct {
|
||||||
execTool *ExecTool
|
execTool *ExecTool
|
||||||
allowCommand bool
|
allowCommand bool
|
||||||
execEnabled bool
|
execEnabled bool
|
||||||
|
commandAllowedRemotes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCronTool creates a new CronTool
|
// NewCronTool creates a new CronTool
|
||||||
|
|
@ -42,9 +43,11 @@ func NewCronTool(
|
||||||
) (*CronTool, error) {
|
) (*CronTool, error) {
|
||||||
allowCommand := true
|
allowCommand := true
|
||||||
execEnabled := true
|
execEnabled := true
|
||||||
|
var commandAllowedRemotes []string
|
||||||
if config != nil {
|
if config != nil {
|
||||||
allowCommand = config.Tools.Cron.AllowCommand
|
allowCommand = config.Tools.Cron.AllowCommand
|
||||||
execEnabled = config.Tools.Exec.Enabled
|
execEnabled = config.Tools.Exec.Enabled
|
||||||
|
commandAllowedRemotes = config.Tools.Cron.CommandAllowedRemotes
|
||||||
}
|
}
|
||||||
|
|
||||||
var execTool *ExecTool
|
var execTool *ExecTool
|
||||||
|
|
@ -66,6 +69,7 @@ func NewCronTool(
|
||||||
execTool: execTool,
|
execTool: execTool,
|
||||||
allowCommand: allowCommand,
|
allowCommand: allowCommand,
|
||||||
execEnabled: execEnabled,
|
execEnabled: execEnabled,
|
||||||
|
commandAllowedRemotes: commandAllowedRemotes,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,11 +154,11 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
|
||||||
case "update":
|
case "update":
|
||||||
return t.updateJob(ctx, args)
|
return t.updateJob(ctx, args)
|
||||||
case "remove":
|
case "remove":
|
||||||
return t.removeJob(args)
|
return t.removeJob(ctx, args)
|
||||||
case "enable":
|
case "enable":
|
||||||
return t.enableJob(args, true)
|
return t.enableJob(ctx, args, true)
|
||||||
case "disable":
|
case "disable":
|
||||||
return t.enableJob(args, false)
|
return t.enableJob(ctx, args, false)
|
||||||
default:
|
default:
|
||||||
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
|
||||||
}
|
}
|
||||||
|
|
@ -217,8 +221,10 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
|
||||||
if !t.execEnabled {
|
if !t.execEnabled {
|
||||||
return ErrorResult("command execution is disabled")
|
return ErrorResult("command execution is disabled")
|
||||||
}
|
}
|
||||||
if !constants.IsInternalChannel(channel) {
|
if !constants.IsInternalChannel(channel) && !isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes) {
|
||||||
return ErrorResult("scheduling command execution is restricted to internal channels")
|
return ErrorResult(
|
||||||
|
"scheduling command execution is restricted to internal channels or configured remote channels",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if !t.allowCommand && !commandConfirm {
|
if !t.allowCommand && !commandConfirm {
|
||||||
return ErrorResult("command_confirm=true is required when allow_command is disabled")
|
return ErrorResult("command_confirm=true is required when allow_command is disabled")
|
||||||
|
|
@ -371,12 +377,20 @@ func (t *CronTool) updateJob(ctx context.Context, args map[string]any) *ToolResu
|
||||||
return SilentResult(fmt.Sprintf("Cron job updated:\n%s", formatCronJobJSON(updated)))
|
return SilentResult(fmt.Sprintf("Cron job updated:\n%s", formatCronJobJSON(updated)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) removeJob(args map[string]any) *ToolResult {
|
func (t *CronTool) removeJob(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
jobID, ok := args["job_id"].(string)
|
jobID, ok := args["job_id"].(string)
|
||||||
if !ok || jobID == "" {
|
if !ok || jobID == "" {
|
||||||
return ErrorResult("job_id is required for remove")
|
return ErrorResult("job_id is required for remove")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
job, ok := t.cronService.GetJob(jobID)
|
||||||
|
if !ok {
|
||||||
|
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||||
|
}
|
||||||
|
if !t.canAccessJob(ctx, job) {
|
||||||
|
return ErrorResult(fmt.Sprintf("Job %s is not accessible from this channel", jobID))
|
||||||
|
}
|
||||||
|
|
||||||
if t.cronService.RemoveJob(jobID) {
|
if t.cronService.RemoveJob(jobID) {
|
||||||
return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
|
return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
|
||||||
}
|
}
|
||||||
|
|
@ -483,11 +497,15 @@ func positiveSeconds(args map[string]any, key string) (int64, *ToolResult) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]any) *ToolResult {
|
func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
|
channel := ToolChannel(ctx)
|
||||||
|
chatID := ToolChatID(ctx)
|
||||||
if !t.execEnabled {
|
if !t.execEnabled {
|
||||||
return ErrorResult("command execution is disabled")
|
return ErrorResult("command execution is disabled")
|
||||||
}
|
}
|
||||||
if !constants.IsInternalChannel(ToolChannel(ctx)) {
|
if !constants.IsInternalChannel(channel) && !isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes) {
|
||||||
return ErrorResult("updating command execution is restricted to internal channels")
|
return ErrorResult(
|
||||||
|
"updating command execution is restricted to internal channels or configured remote channels",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
commandConfirm, _ := args["command_confirm"].(bool)
|
commandConfirm, _ := args["command_confirm"].(bool)
|
||||||
if !t.allowCommand && !commandConfirm {
|
if !t.allowCommand && !commandConfirm {
|
||||||
|
|
@ -496,19 +514,46 @@ func (t *CronTool) validateCommandMutation(ctx context.Context, args map[string]
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCommandAllowedRemote(channel, chatID string, allowed []string) bool {
|
||||||
|
if channel == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
target := channel
|
||||||
|
if chatID != "" {
|
||||||
|
target = channel + ":" + chatID
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range allowed {
|
||||||
|
entry = strings.TrimSpace(entry)
|
||||||
|
if entry == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entry == "*" || entry == channel || entry == target {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (t *CronTool) canAccessJob(ctx context.Context, job *cron.CronJob) bool {
|
func (t *CronTool) canAccessJob(ctx context.Context, job *cron.CronJob) bool {
|
||||||
channel := ToolChannel(ctx)
|
channel := ToolChannel(ctx)
|
||||||
if constants.IsInternalChannel(channel) {
|
if constants.IsInternalChannel(channel) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
chatID := ToolChatID(ctx)
|
chatID := ToolChatID(ctx)
|
||||||
if channel == "" || chatID == "" {
|
if channel == "" || chatID == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if job.Payload.Command != "" {
|
if job.Payload.Channel != channel || job.Payload.To != chatID {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return job.Payload.Channel == channel && job.Payload.To == chatID
|
if job.Payload.Command != "" {
|
||||||
|
return isCommandAllowedRemote(channel, chatID, t.commandAllowedRemotes)
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatCronJobJSON(job *cron.CronJob) string {
|
func formatCronJobJSON(job *cron.CronJob) string {
|
||||||
|
|
@ -519,14 +564,22 @@ func formatCronJobJSON(job *cron.CronJob) string {
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
|
func (t *CronTool) enableJob(ctx context.Context, args map[string]any, enable bool) *ToolResult {
|
||||||
jobID, ok := args["job_id"].(string)
|
jobID, ok := args["job_id"].(string)
|
||||||
if !ok || jobID == "" {
|
if !ok || jobID == "" {
|
||||||
return ErrorResult("job_id is required for enable/disable")
|
return ErrorResult("job_id is required for enable/disable")
|
||||||
}
|
}
|
||||||
|
|
||||||
job := t.cronService.EnableJob(jobID, enable)
|
job, ok := t.cronService.GetJob(jobID)
|
||||||
if job == nil {
|
if !ok {
|
||||||
|
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||||
|
}
|
||||||
|
if !t.canAccessJob(ctx, job) {
|
||||||
|
return ErrorResult(fmt.Sprintf("Job %s is not accessible from this channel", jobID))
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedJob := t.cronService.EnableJob(jobID, enable)
|
||||||
|
if updatedJob == nil {
|
||||||
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -534,7 +587,7 @@ func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
|
||||||
if !enable {
|
if !enable {
|
||||||
status = "disabled"
|
status = "disabled"
|
||||||
}
|
}
|
||||||
return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status))
|
return SilentResult(fmt.Sprintf("Cron job '%s' %s", updatedJob.Name, status))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteJob executes a cron job through the agent
|
// ExecuteJob executes a cron job through the agent
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,29 @@ func parseCronJobResult(t *testing.T, result *ToolResult) cron.CronJob {
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
|
func addTestCronJob(t *testing.T, tool *CronTool, name, channel, chatID, command string) *cron.CronJob {
|
||||||
|
t.Helper()
|
||||||
|
everyMS := int64(60_000)
|
||||||
|
job, err := tool.cronService.AddJob(
|
||||||
|
name,
|
||||||
|
cron.CronSchedule{Kind: "every", EveryMS: &everyMS},
|
||||||
|
name+" message",
|
||||||
|
channel,
|
||||||
|
chatID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddJob() error: %v", err)
|
||||||
|
}
|
||||||
|
if command != "" {
|
||||||
|
job.Payload.Command = command
|
||||||
|
if err := tool.cronService.UpdateJob(job); err != nil {
|
||||||
|
t.Fatalf("UpdateJob() error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return job
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted by default.
|
||||||
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
|
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
|
||||||
tool := newTestCronTool(t)
|
tool := newTestCronTool(t)
|
||||||
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
@ -102,8 +124,148 @@ func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
|
||||||
if !result.IsError {
|
if !result.IsError {
|
||||||
t.Fatal("expected command scheduling to be blocked from remote channel")
|
t.Fatal("expected command scheduling to be blocked from remote channel")
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.ForLLM, "restricted to internal channels") {
|
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
|
||||||
t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM)
|
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandAllowedFromRemoteChannelAllowlist(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected command scheduling from allowed remote channel to succeed, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandAllowedFromRemoteChatIDAllowlist(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{" telegram:1234567890 "}
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "1234567890")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected command scheduling from allowed remote chat to succeed, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandAllowedFromRemoteWildcardAllowlist(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected wildcard allowlist to allow remote command scheduling, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandAllowedRemoteWildcardRequiresNonEmptyChannel(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "", "chat-1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected missing channel to remain blocked even with wildcard allowlist")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "no session context") {
|
||||||
|
t.Errorf("expected session context error, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandBlockedFromDifferentRemoteChatID(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:1234567890"}
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "other-chat")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"command_confirm": true,
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected command scheduling from non-allowlisted remote chat to fail")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
|
||||||
|
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandAllowedRemoteRequiresConfirmWhenAllowCommandDisabled(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.AllowCommand = false
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected allowlisted remote command scheduling to require confirm when allow_command is disabled")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "command_confirm=true") {
|
||||||
|
t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_AllowCommandDoesNotBypassRemoteAllowlist(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.AllowCommand = true
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
result := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "add",
|
||||||
|
"message": "check disk",
|
||||||
|
"command": "df -h",
|
||||||
|
"at_seconds": float64(60),
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Fatal("expected allow_command=true not to bypass remote allowlist")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "restricted to internal channels or configured remote channels") {
|
||||||
|
t.Errorf("expected remote restriction message, got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -554,6 +716,296 @@ func TestCronTool_RemoteCannotAccessCommandJob(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCronTool_AllowlistedRemoteCanAccessOwnCommandJob(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:chat-1"}
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
|
||||||
|
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
|
||||||
|
t.Fatalf("expected list to include own command job, got: %+v", listResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
|
||||||
|
if getResult.IsError {
|
||||||
|
t.Fatalf("expected get to access own command job, got: %s", getResult.ForLLM)
|
||||||
|
}
|
||||||
|
got := parseCronJobResult(t, getResult)
|
||||||
|
if got.ID != job.ID || got.Payload.Command != "df -h" {
|
||||||
|
t.Fatalf("get returned wrong command job: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResult := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "update",
|
||||||
|
"job_id": job.ID,
|
||||||
|
"message": "updated command description",
|
||||||
|
})
|
||||||
|
if updateResult.IsError {
|
||||||
|
t.Fatalf("expected update to access own command job, got: %s", updateResult.ForLLM)
|
||||||
|
}
|
||||||
|
updated, _ := tool.cronService.GetJob(job.ID)
|
||||||
|
if updated.Payload.Message != "updated command description" || updated.Payload.Command != "df -h" {
|
||||||
|
t.Fatalf("update returned wrong command payload: %+v", updated.Payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_AllowlistedRemoteCannotAccessOtherChatCommandJob(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-2", "df -h")
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
|
||||||
|
if listResult.IsError || strings.Contains(listResult.ForLLM, job.ID) {
|
||||||
|
t.Fatalf("expected list to hide other chat command job, got: %+v", listResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, action := range []string{"get", "update"} {
|
||||||
|
args := map[string]any{"action": action, "job_id": job.ID}
|
||||||
|
if action == "update" {
|
||||||
|
args["message"] = "changed"
|
||||||
|
}
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
|
||||||
|
t.Fatalf("expected %s to reject other chat command job, got: %+v", action, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_NonAllowlistedRemoteCannotAccessOwnCommandJob(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
|
||||||
|
if listResult.IsError || strings.Contains(listResult.ForLLM, job.ID) {
|
||||||
|
t.Fatalf("expected list to hide non-allowlisted command job, got: %+v", listResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, action := range []string{"get", "update"} {
|
||||||
|
args := map[string]any{"action": action, "job_id": job.ID}
|
||||||
|
if action == "update" {
|
||||||
|
args["message"] = "changed"
|
||||||
|
}
|
||||||
|
result := tool.Execute(ctx, args)
|
||||||
|
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
|
||||||
|
t.Fatalf("expected %s to reject non-allowlisted command job, got: %+v", action, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_WildcardRemoteCanAccessOwnCommandJob(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
|
||||||
|
other := addTestCronJob(t, tool, "other", "telegram", "chat-2", "uptime")
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
|
||||||
|
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
|
||||||
|
t.Fatalf("expected wildcard list to include own command job, got: %+v", listResult)
|
||||||
|
}
|
||||||
|
if strings.Contains(listResult.ForLLM, other.ID) {
|
||||||
|
t.Fatalf("wildcard list should still hide other chat job, got: %s", listResult.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
|
||||||
|
if getResult.IsError {
|
||||||
|
t.Fatalf("expected wildcard get to access own command job, got: %s", getResult.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_InternalChannelCanAccessAllCommandJobs(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
|
||||||
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
|
|
||||||
|
listResult := tool.Execute(ctx, map[string]any{"action": "list"})
|
||||||
|
if listResult.IsError || !strings.Contains(listResult.ForLLM, job.ID) {
|
||||||
|
t.Fatalf("expected internal list to include command job, got: %+v", listResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
getResult := tool.Execute(ctx, map[string]any{"action": "get", "job_id": job.ID})
|
||||||
|
if getResult.IsError {
|
||||||
|
t.Fatalf("expected internal get to access command job, got: %s", getResult.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateResult := tool.Execute(ctx, map[string]any{
|
||||||
|
"action": "update",
|
||||||
|
"job_id": job.ID,
|
||||||
|
"message": "internal update",
|
||||||
|
})
|
||||||
|
if updateResult.IsError {
|
||||||
|
t.Fatalf("expected internal update to access command job, got: %s", updateResult.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_AllowlistedRemoteCanManageOwnCommandJob(t *testing.T) {
|
||||||
|
for _, action := range []string{"remove", "enable", "disable"} {
|
||||||
|
t.Run(action, func(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram:chat-1"}
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
|
||||||
|
if action == "enable" {
|
||||||
|
tool.cronService.EnableJob(job.ID, false)
|
||||||
|
}
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected %s to access own command job, got: %s", action, result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, ok := tool.cronService.GetJob(job.ID)
|
||||||
|
switch action {
|
||||||
|
case "remove":
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("remove should delete own command job: %+v", saved)
|
||||||
|
}
|
||||||
|
case "enable":
|
||||||
|
if !ok || !saved.Enabled {
|
||||||
|
t.Fatalf("enable should enable own command job: %+v", saved)
|
||||||
|
}
|
||||||
|
case "disable":
|
||||||
|
if !ok || saved.Enabled {
|
||||||
|
t.Fatalf("disable should disable own command job: %+v", saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_RemoteCannotManageOtherChatJob(t *testing.T) {
|
||||||
|
for _, action := range []string{"remove", "enable", "disable"} {
|
||||||
|
t.Run(action, func(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"telegram"}
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-2", "df -h")
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
|
||||||
|
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
|
||||||
|
t.Fatalf("expected %s to reject other chat job, got: %+v", action, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, ok := tool.cronService.GetJob(job.ID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s should not remove other chat job", action)
|
||||||
|
}
|
||||||
|
if !saved.Enabled {
|
||||||
|
t.Fatalf("%s should not disable other chat job: %+v", action, saved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_RemoteCannotManageCommandJobUnlessAllowlisted(t *testing.T) {
|
||||||
|
for _, action := range []string{"remove", "enable", "disable"} {
|
||||||
|
t.Run(action, func(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
|
||||||
|
if !result.IsError || !strings.Contains(result.ForLLM, "not accessible") {
|
||||||
|
t.Fatalf("expected %s to reject non-allowlisted command job, got: %+v", action, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, ok := tool.cronService.GetJob(job.ID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s should not remove non-allowlisted command job", action)
|
||||||
|
}
|
||||||
|
if !saved.Enabled {
|
||||||
|
t.Fatalf("%s should not disable non-allowlisted command job: %+v", action, saved)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_InternalChannelCanManageAllJobs(t *testing.T) {
|
||||||
|
for _, action := range []string{"remove", "enable", "disable"} {
|
||||||
|
t.Run(action, func(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
|
||||||
|
if action == "enable" {
|
||||||
|
tool.cronService.EnableJob(job.ID, false)
|
||||||
|
}
|
||||||
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected internal %s to access command job, got: %s", action, result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
saved, ok := tool.cronService.GetJob(job.ID)
|
||||||
|
switch action {
|
||||||
|
case "remove":
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("internal remove should delete command job: %+v", saved)
|
||||||
|
}
|
||||||
|
case "enable":
|
||||||
|
if !ok || !saved.Enabled {
|
||||||
|
t.Fatalf("internal enable should enable command job: %+v", saved)
|
||||||
|
}
|
||||||
|
case "disable":
|
||||||
|
if !ok || saved.Enabled {
|
||||||
|
t.Fatalf("internal disable should disable command job: %+v", saved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_RemoteCanManageOwnNonCommandJob(t *testing.T) {
|
||||||
|
for _, action := range []string{"remove", "enable", "disable"} {
|
||||||
|
t.Run(action, func(t *testing.T) {
|
||||||
|
tool := newTestCronTool(t)
|
||||||
|
job := addTestCronJob(t, tool, "reminder", "telegram", "chat-1", "")
|
||||||
|
if action == "enable" {
|
||||||
|
tool.cronService.EnableJob(job.ID, false)
|
||||||
|
}
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected %s to access own non-command job, got: %s", action, result.ForLLM)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronTool_WildcardRemoteCanManageOwnCommandJob(t *testing.T) {
|
||||||
|
for _, action := range []string{"remove", "enable", "disable"} {
|
||||||
|
t.Run(action, func(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Cron.CommandAllowedRemotes = []string{"*"}
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
job := addTestCronJob(t, tool, "command", "telegram", "chat-1", "df -h")
|
||||||
|
if action == "enable" {
|
||||||
|
tool.cronService.EnableJob(job.ID, false)
|
||||||
|
}
|
||||||
|
other := addTestCronJob(t, tool, "other", "telegram", "chat-2", "uptime")
|
||||||
|
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
|
||||||
|
|
||||||
|
result := tool.Execute(ctx, map[string]any{"action": action, "job_id": job.ID})
|
||||||
|
if result.IsError {
|
||||||
|
t.Fatalf("expected wildcard %s to access own command job, got: %s", action, result.ForLLM)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherResult := tool.Execute(ctx, map[string]any{"action": action, "job_id": other.ID})
|
||||||
|
if !otherResult.IsError || !strings.Contains(otherResult.ForLLM, "not accessible") {
|
||||||
|
t.Fatalf("wildcard %s should still reject other chat job, got: %+v", action, otherResult)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCronTool_CommandUpdateSafetyGates(t *testing.T) {
|
func TestCronTool_CommandUpdateSafetyGates(t *testing.T) {
|
||||||
t.Run("exec disabled", func(t *testing.T) {
|
t.Run("exec disabled", func(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue