diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 8baf3e6fd..7ec0e90ba 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -384,6 +384,13 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { } } +func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true") + } +} + func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -400,6 +407,22 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { } } +func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("tools.exec.allow_remote should remain true when unset in config file") + } +} + func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 56cc95375..50f9d58ac 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -427,7 +427,7 @@ func DefaultConfig() *Config { Enabled: true, }, EnableDenyPatterns: true, - AllowRemote: false, + AllowRemote: true, TimeoutSeconds: 60, }, Skills: SkillsToolsConfig{ diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index e272d17a9..e95c2f3ec 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -1111,6 +1111,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig { Exec: config.ExecConfig{ EnableDenyPatterns: c.Exec.EnableDenyPatterns, CustomDenyPatterns: c.Exec.CustomDenyPatterns, + AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote, }, } } diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go index 3a7d0c686..802693825 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config_test.go +++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go @@ -290,6 +290,20 @@ func TestConvertToPicoClaw(t *testing.T) { } } +func TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) { + cfg := (&PicoClawConfig{ + Tools: ToolsConfig{ + Exec: ExecConfig{ + EnableDenyPatterns: true, + }, + }, + }).ToStandardConfig() + + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("ToStandardConfig() should preserve the default tools.exec.allow_remote=true") + } +} + func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") diff --git a/web/backend/api/config.go b/web/backend/api/config.go index e261f43dc..091e3fbae 100644 --- a/web/backend/api/config.go +++ b/web/backend/api/config.go @@ -48,6 +48,9 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) return } + if execAllowRemoteOmitted(body) { + cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote + } if errs := validateConfig(&cfg); len(errs) > 0 { w.Header().Set("Content-Type", "application/json") @@ -68,6 +71,20 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } +func execAllowRemoteOmitted(body []byte) bool { + var raw struct { + Tools *struct { + Exec *struct { + AllowRemote *bool `json:"allow_remote"` + } `json:"exec"` + } `json:"tools"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return false + } + return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil +} + // handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396). // Only the fields present in the request body will be updated; all other fields remain unchanged. // diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go new file mode 100644 index 000000000..29811e37e --- /dev/null +++ b/web/backend/api/config_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_key": "sk-default" + } + ] + }`)) + 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()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config") + } +} + +func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_key": "sk-default" + } + ] + }`)) + 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()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := cfg.ModelList[0].APIBase; got != "" { + t.Fatalf("model_list[0].api_base = %q, want empty string", got) + } +} diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index c2d502079..d7e1aa1b5 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -189,6 +189,11 @@ export function ConfigPage() { session: { dm_scope: dmScope, }, + tools: { + exec: { + allow_remote: form.allowRemote, + }, + }, heartbeat: { enabled: form.heartbeatEnabled, interval: heartbeatInterval, diff --git a/web/frontend/src/components/config/config-sections.tsx b/web/frontend/src/components/config/config-sections.tsx index 340ece333..90813be2a 100644 --- a/web/frontend/src/components/config/config-sections.tsx +++ b/web/frontend/src/components/config/config-sections.tsx @@ -63,6 +63,13 @@ export function AgentDefaultsSection({ } /> + onFieldChange("allowRemote", checked)} + /> + export interface CoreConfigForm { workspace: string restrictToWorkspace: boolean + allowRemote: boolean maxTokens: string maxToolIterations: string summarizeMessageThreshold: string @@ -54,6 +55,7 @@ export const DM_SCOPE_OPTIONS = [ export const EMPTY_FORM: CoreConfigForm = { workspace: "", restrictToWorkspace: true, + allowRemote: true, maxTokens: "32768", maxToolIterations: "50", summarizeMessageThreshold: "20", @@ -103,6 +105,8 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { const session = asRecord(root.session) const heartbeat = asRecord(root.heartbeat) const devices = asRecord(root.devices) + const tools = asRecord(root.tools) + const exec = asRecord(tools.exec) return { workspace: asString(defaults.workspace) || EMPTY_FORM.workspace, @@ -110,6 +114,10 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm { defaults.restrict_to_workspace === undefined ? EMPTY_FORM.restrictToWorkspace : asBool(defaults.restrict_to_workspace), + allowRemote: + exec.allow_remote === undefined + ? EMPTY_FORM.allowRemote + : asBool(exec.allow_remote), maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens), maxToolIterations: asNumberString( defaults.max_tool_iterations, diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index 875387c8a..b88b5c924 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -429,6 +429,8 @@ "workspace_hint": "Base directory for agent file operations.", "restrict_workspace": "Restrict to Workspace", "restrict_workspace_hint": "Only allow file operations inside workspace.", + "allow_remote": "Allow Remote Shell Execution", + "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.", "max_tokens": "Max Tokens", "max_tokens_hint": "Upper token limit per model response.", "max_tool_iterations": "Max Tool Iterations", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index e40ad625b..12833cbf5 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -429,6 +429,8 @@ "workspace_hint": "智能体执行文件读写操作时使用的基础目录。", "restrict_workspace": "限制工作目录访问", "restrict_workspace_hint": "仅允许在工作目录内执行文件操作。", + "allow_remote": "允许远程执行 Shell 命令", + "allow_remote_hint": "开启后,来自远程会话或非本地上下文的请求也可以执行 shell 命令;关闭后,仅允许本地安全上下文执行。", "max_tokens": "最大 Token 数", "max_tokens_hint": "单次模型响应允许的最大 Token 数。", "max_tool_iterations": "最大工具迭代次数",