Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ func DefaultConfig() *Config {
Enabled: true,
},
EnableDenyPatterns: true,
AllowRemote: false,
AllowRemote: true,
TimeoutSeconds: 60,
},
Skills: SkillsToolsConfig{
Expand Down
1 change: 1 addition & 0 deletions pkg/migrate/sources/openclaw/openclaw_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}
14 changes: 14 additions & 0 deletions pkg/migrate/sources/openclaw/openclaw_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
17 changes: 17 additions & 0 deletions web/backend/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.
//
Expand Down
88 changes: 88 additions & 0 deletions web/backend/api/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions web/frontend/src/components/config/config-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ export function ConfigPage() {
session: {
dm_scope: dmScope,
},
tools: {
exec: {
allow_remote: form.allowRemote,
},
},
heartbeat: {
enabled: form.heartbeatEnabled,
interval: heartbeatInterval,
Expand Down
7 changes: 7 additions & 0 deletions web/frontend/src/components/config/config-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ export function AgentDefaultsSection({
}
/>

<SwitchCardField
label={t("pages.config.allow_remote")}
hint={t("pages.config.allow_remote_hint")}
checked={form.allowRemote}
onCheckedChange={(checked) => onFieldChange("allowRemote", checked)}
/>

<Field
label={t("pages.config.max_tokens")}
hint={t("pages.config.max_tokens_hint")}
Expand Down
8 changes: 8 additions & 0 deletions web/frontend/src/components/config/form-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export type JsonRecord = Record<string, unknown>
export interface CoreConfigForm {
workspace: string
restrictToWorkspace: boolean
allowRemote: boolean
maxTokens: string
maxToolIterations: string
summarizeMessageThreshold: string
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -103,13 +105,19 @@ 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,
restrictToWorkspace:
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,
Expand Down
2 changes: 2 additions & 0 deletions web/frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions web/frontend/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "最大工具迭代次数",
Expand Down
Loading