Skip to content

Commit fcb6986

Browse files
authored
feat(web): add configurable cron command execution settings (#1647)
- add tools.cron.allow_command config with a default value of true - require command_confirm only when cron command execution is disabled - expose cron command permission and timeout settings in the config UI - add backend tests and update i18n strings
1 parent be4a33c commit fcb6986

10 files changed

Lines changed: 174 additions & 21 deletions

File tree

pkg/config/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -699,8 +699,9 @@ type WebToolsConfig struct {
699699
}
700700

701701
type CronToolsConfig struct {
702-
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
703-
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
702+
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_CRON_"`
703+
ExecTimeoutMinutes int ` env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES" json:"exec_timeout_minutes"` // 0 means no timeout
704+
AllowCommand bool ` env:"PICOCLAW_TOOLS_CRON_ALLOW_COMMAND" json:"allow_command"`
704705
}
705706

706707
type ExecConfig struct {

pkg/config/config_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,13 @@ func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
405405
}
406406
}
407407

408+
func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
409+
cfg := DefaultConfig()
410+
if !cfg.Tools.Cron.AllowCommand {
411+
t.Fatal("DefaultConfig().Tools.Cron.AllowCommand should be true")
412+
}
413+
}
414+
408415
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
409416
dir := t.TempDir()
410417
configPath := filepath.Join(dir, "config.json")
@@ -437,6 +444,22 @@ func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
437444
}
438445
}
439446

447+
func TestLoadConfig_CronAllowCommandDefaultsTrueWhenUnset(t *testing.T) {
448+
dir := t.TempDir()
449+
configPath := filepath.Join(dir, "config.json")
450+
if err := os.WriteFile(configPath, []byte(`{"tools":{"cron":{"exec_timeout_minutes":5}}}`), 0o600); err != nil {
451+
t.Fatalf("WriteFile() error: %v", err)
452+
}
453+
454+
cfg, err := LoadConfig(configPath)
455+
if err != nil {
456+
t.Fatalf("LoadConfig() error: %v", err)
457+
}
458+
if !cfg.Tools.Cron.AllowCommand {
459+
t.Fatal("tools.cron.allow_command should remain true when unset in config file")
460+
}
461+
}
462+
440463
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
441464
dir := t.TempDir()
442465
configPath := filepath.Join(dir, "config.json")

pkg/config/defaults.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,7 @@ func DefaultConfig() *Config {
452452
Enabled: true,
453453
},
454454
ExecTimeoutMinutes: 5,
455+
AllowCommand: true,
455456
},
456457
Exec: ExecConfig{
457458
ToolConfig: ToolConfig{

pkg/tools/cron.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@ type JobExecutor interface {
2020

2121
// CronTool provides scheduling capabilities for the agent
2222
type CronTool struct {
23-
cronService *cron.CronService
24-
executor JobExecutor
25-
msgBus *bus.MessageBus
26-
execTool *ExecTool
23+
cronService *cron.CronService
24+
executor JobExecutor
25+
msgBus *bus.MessageBus
26+
execTool *ExecTool
27+
allowCommand bool
2728
}
2829

2930
// NewCronTool creates a new CronTool
@@ -37,12 +38,18 @@ func NewCronTool(
3738
return nil, fmt.Errorf("unable to configure exec tool: %w", err)
3839
}
3940

41+
allowCommand := true
42+
if config != nil {
43+
allowCommand = config.Tools.Cron.AllowCommand
44+
}
45+
4046
execTool.SetTimeout(execTimeout)
4147
return &CronTool{
42-
cronService: cronService,
43-
executor: executor,
44-
msgBus: msgBus,
45-
execTool: execTool,
48+
cronService: cronService,
49+
executor: executor,
50+
msgBus: msgBus,
51+
execTool: execTool,
52+
allowCommand: allowCommand,
4653
}, nil
4754
}
4855

@@ -76,7 +83,7 @@ func (t *CronTool) Parameters() map[string]any {
7683
},
7784
"command_confirm": map[string]any{
7885
"type": "boolean",
79-
"description": "Required when using command=true. Must be true to explicitly confirm scheduling a shell command.",
86+
"description": "Optional explicit confirmation flag for scheduling a shell command. Command execution must also be enabled via tools.cron.allow_command.",
8087
},
8188
"at_seconds": map[string]any{
8289
"type": "integer",
@@ -180,16 +187,17 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
180187
deliver = d
181188
}
182189

183-
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel + explicit confirm.
184-
// Non-command reminders (plain messages) remain open to all channels.
190+
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When
191+
// allow_command is disabled, explicit confirmation is required as an override.
192+
// Non-command reminders remain open to all channels.
185193
command, _ := args["command"].(string)
186194
commandConfirm, _ := args["command_confirm"].(bool)
187195
if command != "" {
188196
if !constants.IsInternalChannel(channel) {
189197
return ErrorResult("scheduling command execution is restricted to internal channels")
190198
}
191-
if !commandConfirm {
192-
return ErrorResult("command_confirm=true is required to schedule command execution")
199+
if !t.allowCommand && !commandConfirm {
200+
return ErrorResult("command_confirm=true is required when allow_command is disabled")
193201
}
194202
deliver = false
195203
}

pkg/tools/cron_test.go

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,23 @@ import (
1111
"github.com/sipeed/picoclaw/pkg/cron"
1212
)
1313

14-
func newTestCronTool(t *testing.T) *CronTool {
14+
func newTestCronToolWithConfig(t *testing.T, cfg *config.Config) *CronTool {
1515
t.Helper()
1616
storePath := filepath.Join(t.TempDir(), "cron.json")
1717
cronService := cron.NewCronService(storePath, nil)
1818
msgBus := bus.NewMessageBus()
19-
cfg := config.DefaultConfig()
2019
tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg)
2120
if err != nil {
2221
t.Fatalf("NewCronTool() error: %v", err)
2322
}
2423
return tool
2524
}
2625

26+
func newTestCronTool(t *testing.T) *CronTool {
27+
t.Helper()
28+
return newTestCronToolWithConfig(t, config.DefaultConfig())
29+
}
30+
2731
// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels
2832
func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
2933
tool := newTestCronTool(t)
@@ -44,8 +48,7 @@ func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) {
4448
}
4549
}
4650

47-
// TestCronTool_CommandRequiresConfirm verifies command_confirm=true is required
48-
func TestCronTool_CommandRequiresConfirm(t *testing.T) {
51+
func TestCronTool_CommandDoesNotRequireConfirmByDefault(t *testing.T) {
4952
tool := newTestCronTool(t)
5053
ctx := WithToolContext(context.Background(), "cli", "direct")
5154
result := tool.Execute(ctx, map[string]any{
@@ -55,11 +58,57 @@ func TestCronTool_CommandRequiresConfirm(t *testing.T) {
5558
"at_seconds": float64(60),
5659
})
5760

61+
if result.IsError {
62+
t.Fatalf("expected command scheduling without confirm to succeed by default, got: %s", result.ForLLM)
63+
}
64+
if !strings.Contains(result.ForLLM, "Cron job added") {
65+
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
66+
}
67+
}
68+
69+
func TestCronTool_CommandRequiresConfirmWhenAllowCommandDisabled(t *testing.T) {
70+
cfg := config.DefaultConfig()
71+
cfg.Tools.Cron.AllowCommand = false
72+
73+
tool := newTestCronToolWithConfig(t, cfg)
74+
ctx := WithToolContext(context.Background(), "cli", "direct")
75+
result := tool.Execute(ctx, map[string]any{
76+
"action": "add",
77+
"message": "check disk",
78+
"command": "df -h",
79+
"at_seconds": float64(60),
80+
})
81+
5882
if !result.IsError {
59-
t.Fatal("expected error when command_confirm is missing")
83+
t.Fatal("expected command scheduling to require confirm when allow_command is disabled")
6084
}
6185
if !strings.Contains(result.ForLLM, "command_confirm=true") {
62-
t.Errorf("expected 'command_confirm=true' message, got: %s", result.ForLLM)
86+
t.Errorf("expected command_confirm requirement message, got: %s", result.ForLLM)
87+
}
88+
}
89+
90+
func TestCronTool_CommandAllowedWithConfirmWhenAllowCommandDisabled(t *testing.T) {
91+
cfg := config.DefaultConfig()
92+
cfg.Tools.Cron.AllowCommand = false
93+
94+
tool := newTestCronToolWithConfig(t, cfg)
95+
ctx := WithToolContext(context.Background(), "cli", "direct")
96+
result := tool.Execute(ctx, map[string]any{
97+
"action": "add",
98+
"message": "check disk",
99+
"command": "df -h",
100+
"command_confirm": true,
101+
"at_seconds": float64(60),
102+
})
103+
104+
if result.IsError {
105+
t.Fatalf(
106+
"expected command scheduling with confirm to succeed when allow_command is disabled, got: %s",
107+
result.ForLLM,
108+
)
109+
}
110+
if !strings.Contains(result.ForLLM, "Cron job added") {
111+
t.Errorf("expected 'Cron job added', got: %s", result.ForLLM)
63112
}
64113
}
65114

web/frontend/src/components/config/config-page.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "@/api/system"
1515
import {
1616
AgentDefaultsSection,
17+
CronSection,
1718
DevicesSection,
1819
LauncherSection,
1920
RuntimeSection,
@@ -164,6 +165,11 @@ export function ConfigPage() {
164165
"Heartbeat interval",
165166
{ min: 1 },
166167
)
168+
const cronExecTimeoutMinutes = parseIntField(
169+
form.cronExecTimeoutMinutes,
170+
"Cron exec timeout",
171+
{ min: 0 },
172+
)
167173

168174
await patchAppConfig({
169175
agents: {
@@ -180,6 +186,10 @@ export function ConfigPage() {
180186
dm_scope: dmScope,
181187
},
182188
tools: {
189+
cron: {
190+
allow_command: form.allowCommand,
191+
exec_timeout_minutes: cronExecTimeoutMinutes,
192+
},
183193
exec: {
184194
allow_remote: form.allowRemote,
185195
},
@@ -279,6 +289,8 @@ export function ConfigPage() {
279289

280290
<RuntimeSection form={form} onFieldChange={updateField} />
281291

292+
<CronSection form={form} onFieldChange={updateField} />
293+
282294
<LauncherSection
283295
launcherForm={launcherForm}
284296
onFieldChange={updateLauncherField}

web/frontend/src/components/config/config-sections.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,42 @@ export function RuntimeSection({ form, onFieldChange }: RuntimeSectionProps) {
236236
)
237237
}
238238

239+
interface CronSectionProps {
240+
form: CoreConfigForm
241+
onFieldChange: UpdateCoreField
242+
}
243+
244+
export function CronSection({ form, onFieldChange }: CronSectionProps) {
245+
const { t } = useTranslation()
246+
247+
return (
248+
<ConfigSectionCard title={t("pages.config.sections.cron")}>
249+
<SwitchCardField
250+
label={t("pages.config.allow_shell_execution")}
251+
hint={t("pages.config.allow_shell_execution_hint")}
252+
layout="setting-row"
253+
checked={form.allowCommand}
254+
onCheckedChange={(checked) => onFieldChange("allowCommand", checked)}
255+
/>
256+
257+
<Field
258+
label={t("pages.config.cron_exec_timeout")}
259+
hint={t("pages.config.cron_exec_timeout_hint")}
260+
layout="setting-row"
261+
>
262+
<Input
263+
type="number"
264+
min={0}
265+
value={form.cronExecTimeoutMinutes}
266+
onChange={(e) =>
267+
onFieldChange("cronExecTimeoutMinutes", e.target.value)
268+
}
269+
/>
270+
</Field>
271+
</ConfigSectionCard>
272+
)
273+
}
274+
239275
interface LauncherSectionProps {
240276
launcherForm: LauncherForm
241277
onFieldChange: UpdateLauncherField

web/frontend/src/components/config/form-model.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export interface CoreConfigForm {
44
workspace: string
55
restrictToWorkspace: boolean
66
allowRemote: boolean
7+
allowCommand: boolean
8+
cronExecTimeoutMinutes: string
79
maxTokens: string
810
maxToolIterations: string
911
summarizeMessageThreshold: string
@@ -56,6 +58,8 @@ export const EMPTY_FORM: CoreConfigForm = {
5658
workspace: "",
5759
restrictToWorkspace: true,
5860
allowRemote: true,
61+
allowCommand: true,
62+
cronExecTimeoutMinutes: "5",
5963
maxTokens: "32768",
6064
maxToolIterations: "50",
6165
summarizeMessageThreshold: "20",
@@ -106,6 +110,7 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
106110
const heartbeat = asRecord(root.heartbeat)
107111
const devices = asRecord(root.devices)
108112
const tools = asRecord(root.tools)
113+
const cron = asRecord(tools.cron)
109114
const exec = asRecord(tools.exec)
110115

111116
return {
@@ -118,6 +123,14 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
118123
exec.allow_remote === undefined
119124
? EMPTY_FORM.allowRemote
120125
: asBool(exec.allow_remote),
126+
allowCommand:
127+
cron.allow_command === undefined
128+
? EMPTY_FORM.allowCommand
129+
: asBool(cron.allow_command),
130+
cronExecTimeoutMinutes: asNumberString(
131+
cron.exec_timeout_minutes,
132+
EMPTY_FORM.cronExecTimeoutMinutes,
133+
),
121134
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
122135
maxToolIterations: asNumberString(
123136
defaults.max_tool_iterations,

web/frontend/src/i18n/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,10 @@
394394
"restrict_workspace_hint": "Only allow file operations inside workspace.",
395395
"allow_remote": "Allow Remote Shell Execution",
396396
"allow_remote_hint": "When enabled, shell commands can also run for remote sessions or non-local contexts. When disabled, shell execution stays limited to local safe contexts.",
397+
"allow_shell_execution": "Allow Shell Execution",
398+
"allow_shell_execution_hint": "Enable scheduled shell commands for cron jobs by default. When disabled, users must pass command_confirm=true to schedule a cron command.",
399+
"cron_exec_timeout": "Cron Command Timeout (minutes)",
400+
"cron_exec_timeout_hint": "Maximum runtime for scheduled shell commands. Set to 0 to disable the timeout.",
397401
"max_tokens": "Max Tokens",
398402
"max_tokens_hint": "Upper token limit per model response.",
399403
"max_tool_iterations": "Max Tool Iterations",
@@ -434,6 +438,7 @@
434438
"sections": {
435439
"agent": "Agent",
436440
"runtime": "Runtime",
441+
"cron": "Cron Tasks",
437442
"launcher": "Service",
438443
"devices": "Devices"
439444
},

web/frontend/src/i18n/locales/zh.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,10 @@
394394
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
395395
"allow_remote": "允许远程执行 Shell 命令",
396396
"allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行 shell 命令;关闭后,仅允许本地安全上下文执行。",
397+
"allow_shell_execution": "允许 Shell 执行",
398+
"allow_shell_execution_hint": "开启后,cron 定时任务默认允许执行 shell 命令。关闭后,必须显式传入 command_confirm=true 才能创建 cron 命令任务。",
399+
"cron_exec_timeout": "定时命令超时(分钟)",
400+
"cron_exec_timeout_hint": "定时 shell 命令的最长执行时间。设置为 0 表示不限制超时。",
397401
"max_tokens": "最大 Token 数",
398402
"max_tokens_hint": "单次模型响应允许的最大 Token 数。",
399403
"max_tool_iterations": "最大工具迭代次数",
@@ -434,6 +438,7 @@
434438
"sections": {
435439
"agent": "智能体",
436440
"runtime": "运行时",
441+
"cron": "定时任务",
437442
"launcher": "服务参数",
438443
"devices": "设备"
439444
},

0 commit comments

Comments
 (0)