feat(config): add exec controls and gate cron commands on exec settings (#1685)
- add a dedicated exec settings section in the config page - support timeout and custom allow/deny regex patterns for exec - validate custom exec regex patterns in the config API - block cron command scheduling and execution when exec is disabled - update tests and i18n strings for the new command settings
This commit is contained in:
parent
8a44410e37
commit
7b9fdaec32
9 changed files with 379 additions and 28 deletions
|
|
@ -25,6 +25,7 @@ type CronTool struct {
|
||||||
msgBus *bus.MessageBus
|
msgBus *bus.MessageBus
|
||||||
execTool *ExecTool
|
execTool *ExecTool
|
||||||
allowCommand bool
|
allowCommand bool
|
||||||
|
execEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCronTool creates a new CronTool
|
// NewCronTool creates a new CronTool
|
||||||
|
|
@ -33,23 +34,32 @@ func NewCronTool(
|
||||||
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
|
||||||
execTimeout time.Duration, config *config.Config,
|
execTimeout time.Duration, config *config.Config,
|
||||||
) (*CronTool, error) {
|
) (*CronTool, error) {
|
||||||
execTool, err := NewExecToolWithConfig(workspace, restrict, config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("unable to configure exec tool: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
allowCommand := true
|
allowCommand := true
|
||||||
|
execEnabled := true
|
||||||
if config != nil {
|
if config != nil {
|
||||||
allowCommand = config.Tools.Cron.AllowCommand
|
allowCommand = config.Tools.Cron.AllowCommand
|
||||||
|
execEnabled = config.Tools.Exec.Enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
execTool.SetTimeout(execTimeout)
|
var execTool *ExecTool
|
||||||
|
if execEnabled {
|
||||||
|
var err error
|
||||||
|
execTool, err = NewExecToolWithConfig(workspace, restrict, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to configure exec tool: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if execTool != nil {
|
||||||
|
execTool.SetTimeout(execTimeout)
|
||||||
|
}
|
||||||
return &CronTool{
|
return &CronTool{
|
||||||
cronService: cronService,
|
cronService: cronService,
|
||||||
executor: executor,
|
executor: executor,
|
||||||
msgBus: msgBus,
|
msgBus: msgBus,
|
||||||
execTool: execTool,
|
execTool: execTool,
|
||||||
allowCommand: allowCommand,
|
allowCommand: allowCommand,
|
||||||
|
execEnabled: execEnabled,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,6 +203,9 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
|
||||||
command, _ := args["command"].(string)
|
command, _ := args["command"].(string)
|
||||||
commandConfirm, _ := args["command_confirm"].(bool)
|
commandConfirm, _ := args["command_confirm"].(bool)
|
||||||
if command != "" {
|
if command != "" {
|
||||||
|
if !t.execEnabled {
|
||||||
|
return ErrorResult("command execution is disabled")
|
||||||
|
}
|
||||||
if !constants.IsInternalChannel(channel) {
|
if !constants.IsInternalChannel(channel) {
|
||||||
return ErrorResult("scheduling command execution is restricted to internal channels")
|
return ErrorResult("scheduling command execution is restricted to internal channels")
|
||||||
}
|
}
|
||||||
|
|
@ -298,6 +311,18 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
|
||||||
|
|
||||||
// Execute command if present
|
// Execute command if present
|
||||||
if job.Payload.Command != "" {
|
if job.Payload.Command != "" {
|
||||||
|
if !t.execEnabled || t.execTool == nil {
|
||||||
|
output := "Error executing scheduled command: command execution is disabled"
|
||||||
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
|
Channel: channel,
|
||||||
|
ChatID: chatID,
|
||||||
|
Content: output,
|
||||||
|
})
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"command": job.Payload.Command,
|
"command": job.Payload.Command,
|
||||||
"__channel": channel,
|
"__channel": channel,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -112,6 +113,28 @@ func TestCronTool_CommandAllowedWithConfirmWhenAllowCommandDisabled(t *testing.T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCronTool_CommandBlockedWhenExecDisabled(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Exec.Enabled = false
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
ctx := WithToolContext(context.Background(), "cli", "direct")
|
||||||
|
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 to be blocked when exec is disabled")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "command execution is disabled") {
|
||||||
|
t.Errorf("expected exec disabled message, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels
|
// TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels
|
||||||
func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) {
|
func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) {
|
||||||
tool := newTestCronTool(t)
|
tool := newTestCronTool(t)
|
||||||
|
|
@ -185,3 +208,29 @@ func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) {
|
||||||
t.Fatal("expected deliver=false by default for non-command jobs")
|
t.Fatal("expected deliver=false by default for non-command jobs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Tools.Exec.Enabled = false
|
||||||
|
|
||||||
|
tool := newTestCronToolWithConfig(t, cfg)
|
||||||
|
job := &cron.CronJob{}
|
||||||
|
job.Payload.Channel = "cli"
|
||||||
|
job.Payload.To = "direct"
|
||||||
|
job.Payload.Command = "df -h"
|
||||||
|
|
||||||
|
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
|
||||||
|
t.Fatalf("ExecuteJob() = %q, want ok", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
msg, ok := tool.msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected outbound message")
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.Content, "command execution is disabled") {
|
||||||
|
t.Fatalf("expected exec disabled message, got: %s", msg.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
@ -188,6 +189,27 @@ func validateConfig(cfg *config.Config) []string {
|
||||||
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.Tools.Exec.Enabled {
|
||||||
|
if cfg.Tools.Exec.EnableDenyPatterns {
|
||||||
|
errs = append(
|
||||||
|
errs,
|
||||||
|
validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...)
|
||||||
|
}
|
||||||
|
errs = append(
|
||||||
|
errs,
|
||||||
|
validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRegexPatterns(field string, patterns []string) []string {
|
||||||
|
var errs []string
|
||||||
|
for index, pattern := range patterns {
|
||||||
|
if _, err := regexp.Compile(pattern); err != nil {
|
||||||
|
errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,82 @@ func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) {
|
||||||
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
|
t.Fatalf("model_list[0].api_base = %q, want empty string", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_RejectsInvalidExecRegexPatterns(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"tools": {
|
||||||
|
"exec": {
|
||||||
|
"custom_deny_patterns": ["("]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !bytes.Contains(rec.Body.Bytes(), []byte("custom_deny_patterns")) {
|
||||||
|
t.Fatalf("expected validation error mentioning custom_deny_patterns, body=%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"tools": {
|
||||||
|
"exec": {
|
||||||
|
"enabled": false,
|
||||||
|
"custom_deny_patterns": ["("],
|
||||||
|
"custom_allow_patterns": ["("]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
|
||||||
|
configPath, cleanup := setupOAuthTestEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
|
||||||
|
"tools": {
|
||||||
|
"exec": {
|
||||||
|
"enabled": true,
|
||||||
|
"enable_deny_patterns": false,
|
||||||
|
"custom_deny_patterns": ["("]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
AgentDefaultsSection,
|
AgentDefaultsSection,
|
||||||
CronSection,
|
CronSection,
|
||||||
DevicesSection,
|
DevicesSection,
|
||||||
|
ExecSection,
|
||||||
LauncherSection,
|
LauncherSection,
|
||||||
RuntimeSection,
|
RuntimeSection,
|
||||||
} from "@/components/config/config-sections"
|
} from "@/components/config/config-sections"
|
||||||
|
|
@ -27,6 +28,7 @@ import {
|
||||||
buildFormFromConfig,
|
buildFormFromConfig,
|
||||||
parseCIDRText,
|
parseCIDRText,
|
||||||
parseIntField,
|
parseIntField,
|
||||||
|
parseMultilineList,
|
||||||
} from "@/components/config/form-model"
|
} from "@/components/config/form-model"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
@ -170,6 +172,28 @@ export function ConfigPage() {
|
||||||
"Cron exec timeout",
|
"Cron exec timeout",
|
||||||
{ min: 0 },
|
{ min: 0 },
|
||||||
)
|
)
|
||||||
|
const execConfigPatch: Record<string, unknown> = {
|
||||||
|
enabled: form.execEnabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.execEnabled) {
|
||||||
|
execConfigPatch.allow_remote = form.allowRemote
|
||||||
|
execConfigPatch.enable_deny_patterns = form.enableDenyPatterns
|
||||||
|
execConfigPatch.custom_allow_patterns = parseMultilineList(
|
||||||
|
form.customAllowPatternsText,
|
||||||
|
)
|
||||||
|
execConfigPatch.timeout_seconds = parseIntField(
|
||||||
|
form.execTimeoutSeconds,
|
||||||
|
"Exec timeout",
|
||||||
|
{ min: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (form.enableDenyPatterns) {
|
||||||
|
execConfigPatch.custom_deny_patterns = parseMultilineList(
|
||||||
|
form.customDenyPatternsText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await patchAppConfig({
|
await patchAppConfig({
|
||||||
agents: {
|
agents: {
|
||||||
|
|
@ -190,9 +214,7 @@ export function ConfigPage() {
|
||||||
allow_command: form.allowCommand,
|
allow_command: form.allowCommand,
|
||||||
exec_timeout_minutes: cronExecTimeoutMinutes,
|
exec_timeout_minutes: cronExecTimeoutMinutes,
|
||||||
},
|
},
|
||||||
exec: {
|
exec: execConfigPatch,
|
||||||
allow_remote: form.allowRemote,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
heartbeat: {
|
heartbeat: {
|
||||||
enabled: form.heartbeatEnabled,
|
enabled: form.heartbeatEnabled,
|
||||||
|
|
@ -289,6 +311,8 @@ export function ConfigPage() {
|
||||||
|
|
||||||
<RuntimeSection form={form} onFieldChange={updateField} />
|
<RuntimeSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
|
<ExecSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
<CronSection form={form} onFieldChange={updateField} />
|
<CronSection form={form} onFieldChange={updateField} />
|
||||||
|
|
||||||
<LauncherSection
|
<LauncherSection
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,6 @@ export function AgentDefaultsSection({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SwitchCardField
|
|
||||||
label={t("pages.config.allow_remote")}
|
|
||||||
hint={t("pages.config.allow_remote_hint")}
|
|
||||||
layout="setting-row"
|
|
||||||
checked={form.allowRemote}
|
|
||||||
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label={t("pages.config.max_tokens")}
|
label={t("pages.config.max_tokens")}
|
||||||
hint={t("pages.config.max_tokens_hint")}
|
hint={t("pages.config.max_tokens_hint")}
|
||||||
|
|
@ -161,6 +153,98 @@ export function AgentDefaultsSection({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExecSectionProps {
|
||||||
|
form: CoreConfigForm
|
||||||
|
onFieldChange: UpdateCoreField
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExecSection({ form, onFieldChange }: ExecSectionProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigSectionCard title={t("pages.config.sections.exec")}>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.exec_enabled")}
|
||||||
|
hint={t("pages.config.exec_enabled_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
checked={form.execEnabled}
|
||||||
|
onCheckedChange={(checked) => onFieldChange("execEnabled", checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.execEnabled && (
|
||||||
|
<>
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.allow_remote")}
|
||||||
|
hint={t("pages.config.allow_remote_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
checked={form.allowRemote}
|
||||||
|
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchCardField
|
||||||
|
label={t("pages.config.enable_deny_patterns")}
|
||||||
|
hint={t("pages.config.enable_deny_patterns_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
checked={form.enableDenyPatterns}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onFieldChange("enableDenyPatterns", checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.enableDenyPatterns && (
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.custom_deny_patterns")}
|
||||||
|
hint={t("pages.config.custom_deny_patterns_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
controlClassName="md:max-w-md"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={form.customDenyPatternsText}
|
||||||
|
placeholder={t("pages.config.custom_patterns_placeholder")}
|
||||||
|
className="min-h-[88px]"
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("customDenyPatternsText", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.custom_allow_patterns")}
|
||||||
|
hint={t("pages.config.custom_allow_patterns_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
controlClassName="md:max-w-md"
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={form.customAllowPatternsText}
|
||||||
|
placeholder={t("pages.config.custom_patterns_placeholder")}
|
||||||
|
className="min-h-[88px]"
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("customAllowPatternsText", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label={t("pages.config.exec_timeout_seconds")}
|
||||||
|
hint={t("pages.config.exec_timeout_seconds_hint")}
|
||||||
|
layout="setting-row"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.execTimeoutSeconds}
|
||||||
|
onChange={(e) =>
|
||||||
|
onFieldChange("execTimeoutSeconds", e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ConfigSectionCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface RuntimeSectionProps {
|
interface RuntimeSectionProps {
|
||||||
form: CoreConfigForm
|
form: CoreConfigForm
|
||||||
onFieldChange: UpdateCoreField
|
onFieldChange: UpdateCoreField
|
||||||
|
|
@ -251,6 +335,7 @@ export function CronSection({ form, onFieldChange }: CronSectionProps) {
|
||||||
hint={t("pages.config.allow_shell_execution_hint")}
|
hint={t("pages.config.allow_shell_execution_hint")}
|
||||||
layout="setting-row"
|
layout="setting-row"
|
||||||
checked={form.allowCommand}
|
checked={form.allowCommand}
|
||||||
|
disabled={!form.execEnabled}
|
||||||
onCheckedChange={(checked) => onFieldChange("allowCommand", checked)}
|
onCheckedChange={(checked) => onFieldChange("allowCommand", checked)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -262,6 +347,7 @@ export function CronSection({ form, onFieldChange }: CronSectionProps) {
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
|
disabled={!form.execEnabled}
|
||||||
value={form.cronExecTimeoutMinutes}
|
value={form.cronExecTimeoutMinutes}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
onFieldChange("cronExecTimeoutMinutes", e.target.value)
|
onFieldChange("cronExecTimeoutMinutes", e.target.value)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,12 @@ export type JsonRecord = Record<string, unknown>
|
||||||
export interface CoreConfigForm {
|
export interface CoreConfigForm {
|
||||||
workspace: string
|
workspace: string
|
||||||
restrictToWorkspace: boolean
|
restrictToWorkspace: boolean
|
||||||
|
execEnabled: boolean
|
||||||
allowRemote: boolean
|
allowRemote: boolean
|
||||||
|
enableDenyPatterns: boolean
|
||||||
|
customDenyPatternsText: string
|
||||||
|
customAllowPatternsText: string
|
||||||
|
execTimeoutSeconds: string
|
||||||
allowCommand: boolean
|
allowCommand: boolean
|
||||||
cronExecTimeoutMinutes: string
|
cronExecTimeoutMinutes: string
|
||||||
maxTokens: string
|
maxTokens: string
|
||||||
|
|
@ -57,7 +62,12 @@ export const DM_SCOPE_OPTIONS = [
|
||||||
export const EMPTY_FORM: CoreConfigForm = {
|
export const EMPTY_FORM: CoreConfigForm = {
|
||||||
workspace: "",
|
workspace: "",
|
||||||
restrictToWorkspace: true,
|
restrictToWorkspace: true,
|
||||||
|
execEnabled: true,
|
||||||
allowRemote: true,
|
allowRemote: true,
|
||||||
|
enableDenyPatterns: true,
|
||||||
|
customDenyPatternsText: "",
|
||||||
|
customAllowPatternsText: "",
|
||||||
|
execTimeoutSeconds: "0",
|
||||||
allowCommand: true,
|
allowCommand: true,
|
||||||
cronExecTimeoutMinutes: "5",
|
cronExecTimeoutMinutes: "5",
|
||||||
maxTokens: "32768",
|
maxTokens: "32768",
|
||||||
|
|
@ -119,10 +129,32 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||||
defaults.restrict_to_workspace === undefined
|
defaults.restrict_to_workspace === undefined
|
||||||
? EMPTY_FORM.restrictToWorkspace
|
? EMPTY_FORM.restrictToWorkspace
|
||||||
: asBool(defaults.restrict_to_workspace),
|
: asBool(defaults.restrict_to_workspace),
|
||||||
|
execEnabled:
|
||||||
|
exec.enabled === undefined
|
||||||
|
? EMPTY_FORM.execEnabled
|
||||||
|
: asBool(exec.enabled),
|
||||||
allowRemote:
|
allowRemote:
|
||||||
exec.allow_remote === undefined
|
exec.allow_remote === undefined
|
||||||
? EMPTY_FORM.allowRemote
|
? EMPTY_FORM.allowRemote
|
||||||
: asBool(exec.allow_remote),
|
: asBool(exec.allow_remote),
|
||||||
|
enableDenyPatterns:
|
||||||
|
exec.enable_deny_patterns === undefined
|
||||||
|
? EMPTY_FORM.enableDenyPatterns
|
||||||
|
: asBool(exec.enable_deny_patterns),
|
||||||
|
customDenyPatternsText: Array.isArray(exec.custom_deny_patterns)
|
||||||
|
? exec.custom_deny_patterns
|
||||||
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.join("\n")
|
||||||
|
: EMPTY_FORM.customDenyPatternsText,
|
||||||
|
customAllowPatternsText: Array.isArray(exec.custom_allow_patterns)
|
||||||
|
? exec.custom_allow_patterns
|
||||||
|
.filter((value): value is string => typeof value === "string")
|
||||||
|
.join("\n")
|
||||||
|
: EMPTY_FORM.customAllowPatternsText,
|
||||||
|
execTimeoutSeconds: asNumberString(
|
||||||
|
exec.timeout_seconds,
|
||||||
|
EMPTY_FORM.execTimeoutSeconds,
|
||||||
|
),
|
||||||
allowCommand:
|
allowCommand:
|
||||||
cron.allow_command === undefined
|
cron.allow_command === undefined
|
||||||
? EMPTY_FORM.allowCommand
|
? EMPTY_FORM.allowCommand
|
||||||
|
|
@ -191,3 +223,13 @@ export function parseCIDRText(raw: string): string[] {
|
||||||
.map((v) => v.trim())
|
.map((v) => v.trim())
|
||||||
.filter((v) => v.length > 0)
|
.filter((v) => v.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseMultilineList(raw: string): string[] {
|
||||||
|
if (!raw.trim()) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value) => value.length > 0)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -393,12 +393,23 @@
|
||||||
"workspace_hint": "Base directory for agent file operations.",
|
"workspace_hint": "Base directory for agent file operations.",
|
||||||
"restrict_workspace": "Restrict to Workspace",
|
"restrict_workspace": "Restrict to Workspace",
|
||||||
"restrict_workspace_hint": "Only allow file operations inside workspace.",
|
"restrict_workspace_hint": "Only allow file operations inside workspace.",
|
||||||
"allow_remote": "Allow Remote Shell Execution",
|
"exec_enabled": "Allow Commands",
|
||||||
"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.",
|
"exec_enabled_hint": "Enable or disable command execution for the app. When disabled, no command requests will run.",
|
||||||
"allow_shell_execution": "Allow Shell Execution",
|
"allow_remote": "Allow Remote Commands",
|
||||||
"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.",
|
"allow_remote_hint": "When enabled, remote sessions or non-local contexts can also run commands. When disabled, command execution stays limited to local safe contexts.",
|
||||||
"cron_exec_timeout": "Cron Command Timeout (minutes)",
|
"enable_deny_patterns": "Enable Blacklist",
|
||||||
"cron_exec_timeout_hint": "Maximum runtime for scheduled shell commands. Set to 0 to disable the timeout.",
|
"enable_deny_patterns_hint": "When enabled, the app blocks commands that match its built-in dangerous patterns and the custom command blacklist below.",
|
||||||
|
"exec_timeout_seconds": "Command Timeout (seconds)",
|
||||||
|
"exec_timeout_seconds_hint": "Maximum runtime for command requests. Set to 0 to use the default timeout.",
|
||||||
|
"custom_deny_patterns": "Command Blacklist",
|
||||||
|
"custom_deny_patterns_hint": "Add extra command-blocking rules, one regular expression per line. A command matching any rule here will be blocked.",
|
||||||
|
"custom_allow_patterns": "Command Whitelist",
|
||||||
|
"custom_allow_patterns_hint": "Add extra command-allow rules, one regular expression per line. A command matching any rule here skips blacklist matching, but other safety limits still apply.",
|
||||||
|
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
|
||||||
|
"allow_shell_execution": "Allow Scheduled Commands",
|
||||||
|
"allow_shell_execution_hint": "Allow scheduled tasks to run commands by default. When disabled, users must pass command_confirm=true to schedule a command task.",
|
||||||
|
"cron_exec_timeout": "Scheduled Command Timeout (minutes)",
|
||||||
|
"cron_exec_timeout_hint": "Maximum runtime for scheduled commands. Set to 0 to disable the timeout.",
|
||||||
"max_tokens": "Max Tokens",
|
"max_tokens": "Max Tokens",
|
||||||
"max_tokens_hint": "Upper token limit per model response.",
|
"max_tokens_hint": "Upper token limit per model response.",
|
||||||
"max_tool_iterations": "Max Tool Iterations",
|
"max_tool_iterations": "Max Tool Iterations",
|
||||||
|
|
@ -439,6 +450,7 @@
|
||||||
"sections": {
|
"sections": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
"runtime": "Runtime",
|
"runtime": "Runtime",
|
||||||
|
"exec": "Run Commands",
|
||||||
"cron": "Cron Tasks",
|
"cron": "Cron Tasks",
|
||||||
"launcher": "Service",
|
"launcher": "Service",
|
||||||
"devices": "Devices"
|
"devices": "Devices"
|
||||||
|
|
|
||||||
|
|
@ -393,12 +393,23 @@
|
||||||
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
|
"workspace_hint": "智能体执行文件读写操作时使用的基础目录。",
|
||||||
"restrict_workspace": "限制工作目录访问",
|
"restrict_workspace": "限制工作目录访问",
|
||||||
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
|
"restrict_workspace_hint": "仅允许在工作目录内执行文件操作。",
|
||||||
"allow_remote": "允许远程执行 Shell 命令",
|
"exec_enabled": "允许命令执行",
|
||||||
"allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行 shell 命令;关闭后,仅允许本地安全上下文执行。",
|
"exec_enabled_hint": "控制应用是否允许执行命令。关闭后,所有命令请求都不会执行。",
|
||||||
"allow_shell_execution": "允许 Shell 执行",
|
"allow_remote": "允许远程命令执行",
|
||||||
"allow_shell_execution_hint": "开启后,cron 定时任务默认允许执行 shell 命令。关闭后,必须显式传入 command_confirm=true 才能创建 cron 命令任务。",
|
"allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行命令;关闭后,仅允许本地安全上下文执行命令。",
|
||||||
|
"enable_deny_patterns": "启用黑名单",
|
||||||
|
"enable_deny_patterns_hint": "开启后,应用会拦截匹配内置危险模式以及下方自定义命令黑名单的命令。",
|
||||||
|
"exec_timeout_seconds": "命令超时(秒)",
|
||||||
|
"exec_timeout_seconds_hint": "命令请求的最长运行时间。设置为 0 表示使用默认超时。",
|
||||||
|
"custom_deny_patterns": "命令黑名单",
|
||||||
|
"custom_deny_patterns_hint": "用于补充额外的命令拦截规则,每行一个正则表达式。命中任意一条规则的命令都会被阻止。",
|
||||||
|
"custom_allow_patterns": "命令白名单",
|
||||||
|
"custom_allow_patterns_hint": "用于补充额外的命令放行规则,每行一个正则表达式。命中任意一条规则的命令会跳过黑名单检查,但仍受其他安全限制约束。",
|
||||||
|
"custom_patterns_placeholder": "^rm\\s+-rf\\b\n^git\\s+push\\b",
|
||||||
|
"allow_shell_execution": "允许定时任务运行命令",
|
||||||
|
"allow_shell_execution_hint": "开启后,定时任务默认允许运行命令。关闭后,必须显式传入 command_confirm=true 才能创建运行命令的定时任务。",
|
||||||
"cron_exec_timeout": "定时命令超时(分钟)",
|
"cron_exec_timeout": "定时命令超时(分钟)",
|
||||||
"cron_exec_timeout_hint": "定时 shell 命令的最长执行时间。设置为 0 表示不限制超时。",
|
"cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时。",
|
||||||
"max_tokens": "最大 Token 数",
|
"max_tokens": "最大 Token 数",
|
||||||
"max_tokens_hint": "单次模型响应允许的最大 Token 数。",
|
"max_tokens_hint": "单次模型响应允许的最大 Token 数。",
|
||||||
"max_tool_iterations": "最大工具迭代次数",
|
"max_tool_iterations": "最大工具迭代次数",
|
||||||
|
|
@ -439,6 +450,7 @@
|
||||||
"sections": {
|
"sections": {
|
||||||
"agent": "智能体",
|
"agent": "智能体",
|
||||||
"runtime": "运行时",
|
"runtime": "运行时",
|
||||||
|
"exec": "运行命令",
|
||||||
"cron": "定时任务",
|
"cron": "定时任务",
|
||||||
"launcher": "服务参数",
|
"launcher": "服务参数",
|
||||||
"devices": "设备"
|
"devices": "设备"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue