Unified Agent + reusable Go agent core.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Feat/powershell (#33)

* feat: add Windows PowerShell platform support

- Add PowerShell tool implementation for Windows command execution
- Add platformutil package for runtime OS detection
- Auto-enable PowerShell on Windows, Bash on Unix-like systems
- Update system prompt and config with PowerShell awareness

* feat: integrate PowerShell support across all components

- Add PowerShell tool support in engine and console
- Add PowerShell configuration in agent settings and runtime
- Add PowerShell guard policies for security
- Add PowerShell integration in runtime snapshot and registry
- Fix CI: add shell: bash for Windows runner compatibility

* fix: simplify powershell settings integration

* refactor: share shell execution runner

* fix: align shell metadata and powershell path handling

* docs: sync shell and prompt documentation

* fix: align install config generation with console setup

* refactor: simplify shared config bootstrap

---------

Co-authored-by: CHEYAN <tetsuya.ueno@outlook.com>

authored by

Lyric Wai
CHEYAN
and committed by
GitHub
64a3be27 35f47502

+2391 -668
+2
.github/workflows/release.yml
··· 43 43 run: pnpm --dir web/console build 44 44 45 45 - name: Stage console assets 46 + shell: bash 46 47 run: ./scripts/stage-console-assets.sh 47 48 48 49 - name: Run tests ··· 143 144 run: pnpm --dir web/console build 144 145 145 146 - name: Stage console assets 147 + shell: bash 146 148 run: ./scripts/stage-console-assets.sh 147 149 148 150 - name: Install Linux desktop build deps
+4
agent/engine.go
··· 11 11 "github.com/quailyquaily/mistermorph/guard" 12 12 "github.com/quailyquaily/mistermorph/internal/acpclient" 13 13 "github.com/quailyquaily/mistermorph/internal/llmstats" 14 + "github.com/quailyquaily/mistermorph/internal/platformutil" 14 15 "github.com/quailyquaily/mistermorph/internal/runtimeclock" 15 16 "github.com/quailyquaily/mistermorph/llm" 16 17 "github.com/quailyquaily/mistermorph/tools" ··· 240 241 messages := []llm.Message{{Role: "system", Content: systemPrompt}} 241 242 242 243 injectedMeta := runtimeclock.WithRuntimeClockMeta(opts.Meta, time.Now()) 244 + if _, ok := injectedMeta["host_os"]; !ok { 245 + injectedMeta["host_os"] = platformutil.Current() 246 + } 243 247 if metaMsg, ok := buildInjectedMetaMessage(injectedMeta); ok { 244 248 trigger := "" 245 249 if v, ok := injectedMeta["trigger"].(string); ok {
+6
agent/engine_helpers.go
··· 163 163 out["cmd"] = truncateString(strings.TrimSpace(v), 500) 164 164 } 165 165 } 166 + case "powershell": 167 + if opts.IncludeToolParams { 168 + if v, ok := params["cmd"].(string); ok && strings.TrimSpace(v) != "" { 169 + out["cmd"] = truncateString(strings.TrimSpace(v), 500) 170 + } 171 + } 166 172 } 167 173 168 174 if len(out) == 0 {
+17 -3
agent/engine_loop.go
··· 212 212 if len(st.requestedWrites) > 0 { 213 213 missing := missingFiles(st.requestedWrites) 214 214 if len(missing) > 0 { 215 + shellToolNames := availableShellToolNames(e.registry) 215 216 if _, ok := e.registry.Get("write_file"); ok { 217 + nextStep := "Next, call the write_file tool (preferred)" 218 + if len(shellToolNames) > 0 { 219 + nextStep += fmt.Sprintf(" or one of the available shell tools (%s)", strings.Join(shellToolNames, ", ")) 220 + } 221 + nextStep += " to create/update them." 216 222 log.Info("file_write_required", "step", step, "paths", strings.Join(missing, ", ")) 217 223 st.messages = append(st.messages, 218 224 llm.Message{Role: "assistant", Content: result.Text}, 219 - llm.Message{Role: "user", Content: fmt.Sprintf("You must write the requested file(s) before finishing: %s. Next, call the write_file tool (preferred) or bash to create/update them. The file content should be the final markdown/report (do not include meta text like 'Writing to ...').", strings.Join(missing, ", "))}, 225 + llm.Message{Role: "user", Content: fmt.Sprintf("You must write the requested file(s) before finishing: %s. %s The file content should be the final markdown/report (do not include meta text like 'Writing to ...').", strings.Join(missing, ", "), nextStep)}, 220 226 ) 221 227 continue 222 228 } 223 - if _, ok := e.registry.Get("bash"); ok { 229 + if len(shellToolNames) == 1 { 224 230 log.Info("file_write_required", "step", step, "paths", strings.Join(missing, ", ")) 225 231 st.messages = append(st.messages, 226 232 llm.Message{Role: "assistant", Content: result.Text}, 227 - llm.Message{Role: "user", Content: fmt.Sprintf("You must write the requested file(s) before finishing: %s. Next, call the bash tool to create/update them. The file content should be the final markdown/report (do not include meta text like 'Writing to ...').", strings.Join(missing, ", "))}, 233 + llm.Message{Role: "user", Content: fmt.Sprintf("You must write the requested file(s) before finishing: %s. Next, call the %s tool to create/update them. The file content should be the final markdown/report (do not include meta text like 'Writing to ...').", strings.Join(missing, ", "), shellToolNames[0])}, 234 + ) 235 + continue 236 + } 237 + if len(shellToolNames) > 1 { 238 + log.Info("file_write_required", "step", step, "paths", strings.Join(missing, ", ")) 239 + st.messages = append(st.messages, 240 + llm.Message{Role: "assistant", Content: result.Text}, 241 + llm.Message{Role: "user", Content: fmt.Sprintf("You must write the requested file(s) before finishing: %s. Next, call one of the available shell tools (%s) to create/update them. The file content should be the final markdown/report (do not include meta text like 'Writing to ...').", strings.Join(missing, ", "), strings.Join(shellToolNames, ", "))}, 228 242 ) 229 243 continue 230 244 }
+31
agent/engine_run_order_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "strings" 6 7 "testing" 7 8 ··· 39 40 } 40 41 if !strings.Contains(msgs[1].Content, "mister_morph_meta") { 41 42 t.Fatalf("messages[1] = %q, want injected meta", msgs[1].Content) 43 + } 44 + meta := decodeInjectedMeta(t, msgs[1].Content) 45 + if got := strings.TrimSpace(asString(meta["trigger"])); got != "telegram" { 46 + t.Fatalf("meta trigger = %q, want telegram", got) 47 + } 48 + if got := strings.TrimSpace(asString(meta["host_os"])); got == "" { 49 + t.Fatalf("meta host_os should be set: %#v", meta) 42 50 } 43 51 if msgs[2].Content != "HISTORY_CONTEXT" { 44 52 t.Fatalf("messages[2] = %q, want history", msgs[2].Content) ··· 105 113 if !strings.Contains(msgs[1].Content, "mister_morph_meta") { 106 114 t.Fatalf("messages[1] = %q, want injected meta", msgs[1].Content) 107 115 } 116 + meta := decodeInjectedMeta(t, msgs[1].Content) 117 + if got := strings.TrimSpace(asString(meta["host_os"])); got == "" { 118 + t.Fatalf("meta host_os should be set: %#v", meta) 119 + } 108 120 if msgs[2].Role != "user" || !strings.Contains(msgs[2].Content, "[[ Runtime Memory ]]") { 109 121 t.Fatalf("messages[2] = %#v, want runtime memory message", msgs[2]) 110 122 } ··· 118 130 t.Fatalf("messages[4] = %q, want raw task", msgs[4].Content) 119 131 } 120 132 } 133 + 134 + func decodeInjectedMeta(t *testing.T, raw string) map[string]any { 135 + t.Helper() 136 + 137 + var envelope map[string]map[string]any 138 + if err := json.Unmarshal([]byte(raw), &envelope); err != nil { 139 + t.Fatalf("json.Unmarshal(meta) error = %v; raw=%q", err, raw) 140 + } 141 + meta := envelope["mister_morph_meta"] 142 + if meta == nil { 143 + t.Fatalf("decoded meta missing mister_morph_meta: %#v", envelope) 144 + } 145 + return meta 146 + } 147 + 148 + func asString(v any) string { 149 + s, _ := v.(string) 150 + return s 151 + }
+24
agent/prompt_template.go
··· 2 2 3 3 import ( 4 4 _ "embed" 5 + "sort" 5 6 "strings" 6 7 7 8 "github.com/quailyquaily/mistermorph/internal/prompttmpl" ··· 90 91 } 91 92 return prompttmpl.Render(systemPromptTemplate, data) 92 93 } 94 + 95 + func availableShellToolName(registry *tools.Registry) string { 96 + names := availableShellToolNames(registry) 97 + if len(names) != 1 { 98 + return "" 99 + } 100 + return names[0] 101 + } 102 + 103 + func availableShellToolNames(registry *tools.Registry) []string { 104 + if registry == nil { 105 + return nil 106 + } 107 + names := make([]string, 0, 2) 108 + if _, ok := registry.Get("bash"); ok { 109 + names = append(names, "bash") 110 + } 111 + if _, ok := registry.Get("powershell"); ok { 112 + names = append(names, "powershell") 113 + } 114 + sort.Strings(names) 115 + return names 116 + }
+35
agent/prompt_template_test.go
··· 3 3 import ( 4 4 "strings" 5 5 "testing" 6 + 7 + "github.com/quailyquaily/mistermorph/tools" 6 8 ) 7 9 8 10 func TestBuildSystemPrompt_SkillItemsUseAuthProfilesWithoutRequirements(t *testing.T) { ··· 36 38 t.Fatalf("prompt should not include todo workflow without an injected block: %s", prompt) 37 39 } 38 40 } 41 + 42 + func TestBuildSystemPrompt_DoesNotInjectPlatformSection(t *testing.T) { 43 + prompt := BuildSystemPrompt(nil, DefaultPromptSpec()) 44 + 45 + if strings.Contains(prompt, "Platform Information") { 46 + t.Fatalf("prompt should not include platform section: %s", prompt) 47 + } 48 + if strings.Contains(prompt, "Available shell tool") { 49 + t.Fatalf("prompt should not include shell tool section: %s", prompt) 50 + } 51 + } 52 + 53 + func TestAvailableShellToolName_OnlyReturnsSingleRegisteredShell(t *testing.T) { 54 + reg := tools.NewRegistry() 55 + if got := availableShellToolName(reg); got != "" { 56 + t.Fatalf("availableShellToolName() = %q, want empty", got) 57 + } 58 + 59 + reg.Register(&mockTool{name: "bash"}) 60 + if got := availableShellToolName(reg); got != "bash" { 61 + t.Fatalf("availableShellToolName() = %q, want bash", got) 62 + } 63 + 64 + reg.Register(&mockTool{name: "powershell"}) 65 + if got := availableShellToolName(reg); got != "" { 66 + t.Fatalf("availableShellToolName() = %q, want empty when both shells exist", got) 67 + } 68 + 69 + gotNames := availableShellToolNames(reg) 70 + if strings.Join(gotNames, ",") != "bash,powershell" { 71 + t.Fatalf("availableShellToolNames() = %v, want [bash powershell]", gotNames) 72 + } 73 + }
+19 -1
assets/config/config.example.yaml
··· 230 230 # Default max results (tool also enforces a hard cap). 231 231 max_results: 5 232 232 # Dangerous: enables local shell execution. 233 + # Platform auto-detection: On Windows, PowerShell is enabled by default and Bash is disabled. 234 + # On Linux/macOS, Bash is enabled by default and PowerShell is disabled. 235 + # You can override these defaults by explicitly setting enabled: true/false. 233 236 bash: 234 237 # Enable the bash tool. 235 238 # Note: when allowlisted auth profiles are configured, bash is allowed but `curl` is denied by default. 236 - enabled: true 239 + # Default: auto (true on Linux/macOS, false on Windows). 240 + # enabled: true 237 241 # Default timeout for each bash command. 238 242 timeout: "30s" 239 243 # Max combined output bytes kept per stream (stdout/stderr) before truncation. ··· 245 249 # Extra environment variable names to inject into bash execution. 246 250 # These are added on top of the built-in safe env allowlist. 247 251 injected_env_vars: [] # e.g. ["OPENAI_API_BASE", "HTTP_TIMEOUT"] 252 + # Dangerous: enables local PowerShell execution (Windows). 253 + powershell: 254 + # Enable the powershell tool. 255 + # Default: auto (true on Windows, false on Linux/macOS). 256 + # enabled: true 257 + # Default timeout for each powershell command. 258 + timeout: "30s" 259 + # Max combined output bytes kept per stream (stdout/stderr) before truncation. 260 + max_output_bytes: 262144 261 + # Denylist for sensitive local files. If a command references one of these paths, the tool errors. 262 + deny_paths: 263 + - "config.yaml" 264 + # Extra environment variable names to inject into powershell execution. 265 + injected_env_vars: [] 248 266 249 267 # MCP (Model Context Protocol) servers. 250 268 # Connect to external MCP tool servers. Tools are namespaced as mcp_<name>__<tool>.
+5 -5
cmd/mistermorph/benchmark.go
··· 235 235 } 236 236 237 237 func benchmarkProfileNames(values llmutil.RuntimeValues) []string { 238 - names := make([]string, 0, 1+len(values.Profiles)) 239 - if hasBenchmarkableDefaultProfile(values) { 240 - names = append(names, llmutil.RouteProfileDefault) 241 - } 238 + names := make([]string, 0, len(values.Profiles)) 242 239 for name := range values.Profiles { 243 240 name = strings.TrimSpace(name) 244 241 if name == "" || name == llmutil.RouteProfileDefault { ··· 247 244 names = append(names, name) 248 245 } 249 246 if len(names) > 1 { 250 - sort.Strings(names[1:]) 247 + sort.Strings(names) 248 + } 249 + if hasBenchmarkableDefaultProfile(values) { 250 + return append([]string{llmutil.RouteProfileDefault}, names...) 251 251 } 252 252 return names 253 253 }
+83 -234
cmd/mistermorph/consolecmd/agent_settings.go
··· 16 16 "time" 17 17 18 18 "github.com/quailyquaily/mistermorph/integration" 19 + "github.com/quailyquaily/mistermorph/internal/configbootstrap" 19 20 "github.com/quailyquaily/mistermorph/internal/configutil" 20 21 "github.com/quailyquaily/mistermorph/internal/llmbench" 21 22 "github.com/quailyquaily/mistermorph/internal/llmutil" ··· 99 100 URLFetch toolEnabledPayload `json:"url_fetch"` 100 101 WebSearch toolEnabledPayload `json:"web_search"` 101 102 Bash toolEnabledPayload `json:"bash"` 103 + PowerShell toolEnabledPayload `json:"powershell"` 102 104 } 103 105 104 106 type toolsSettingsUpdatePayload struct { ··· 110 112 URLFetch *toolEnabledUpdatePayload `json:"url_fetch,omitempty"` 111 113 WebSearch *toolEnabledUpdatePayload `json:"web_search,omitempty"` 112 114 Bash *toolEnabledUpdatePayload `json:"bash,omitempty"` 115 + PowerShell *toolEnabledUpdatePayload `json:"powershell,omitempty"` 113 116 } 114 117 115 118 type agentSettingsPayload struct { ··· 195 198 configValid = false 196 199 } 197 200 effectiveLLM := settingsFromCurrentRuntime() 198 - doc := newEmptyYAMLDocument() 201 + doc := configbootstrap.NewEmptyDocument() 199 202 if configValid { 200 203 doc, err = loadYAMLDocument(configPath) 201 204 if err != nil { ··· 269 272 } 270 273 271 274 next := readAgentSettingsFromReader(expanded) 272 - doc, docErr := loadYAMLDocumentBytes(serialized) 275 + doc, docErr := configbootstrap.LoadDocumentBytes(serialized) 273 276 if docErr != nil { 274 277 writeError(w, http.StatusInternalServerError, docErr.Error()) 275 278 return ··· 446 449 URLFetch: toolEnabledUpdatePayloadPointer(values.Tools.URLFetch.Enabled), 447 450 WebSearch: toolEnabledUpdatePayloadPointer(values.Tools.WebSearch.Enabled), 448 451 Bash: toolEnabledUpdatePayloadPointer(values.Tools.Bash.Enabled), 452 + PowerShell: toolEnabledUpdatePayloadPointer(values.Tools.PowerShell.Enabled), 449 453 }, 450 454 }) 451 455 } ··· 456 460 if !isInvalidConfigYAMLError(err) { 457 461 return nil, err 458 462 } 459 - doc = newEmptyYAMLDocument() 463 + doc = configbootstrap.NewEmptyDocument() 460 464 } 461 465 current := defaultAgentSettingsPayload() 462 466 if existing, readErr := readAgentSettings(configPath); readErr == nil { ··· 464 468 } else if !isInvalidConfigYAMLError(readErr) && !os.IsNotExist(readErr) { 465 469 return nil, readErr 466 470 } 471 + if err := applyAgentSettingsUpdateDocument(doc, current, values); err != nil { 472 + return nil, err 473 + } 474 + return configbootstrap.MarshalDocument(doc) 475 + } 476 + 477 + func applyAgentSettingsUpdateDocument(doc *yaml.Node, current agentSettingsPayload, values agentSettingsUpdatePayload) error { 467 478 nextLLM := applyLLMSettingsUpdate(current.LLM, values.LLM) 468 - root, err := documentMapping(doc) 479 + root, err := configbootstrap.DocumentMapping(doc) 469 480 if err != nil { 470 - return nil, err 481 + return err 471 482 } 472 483 473 - llmNode := ensureMappingValue(root, llmSettingsKey) 484 + llmNode := configbootstrap.EnsureMappingValue(root, llmSettingsKey) 474 485 applyLLMConfigFieldsUpdate(llmNode, nextLLM.llmConfigFieldsPayload, values.LLM.llmConfigFieldsUpdatePayload) 475 486 if values.LLM.Profiles != nil { 476 487 profiles, err := normalizeLLMProfileSettings(*values.LLM.Profiles) 477 488 if err != nil { 478 - return nil, err 489 + return err 479 490 } 480 491 if err := setLLMProfilesNode(llmNode, profiles, nextLLM.Provider); err != nil { 481 - return nil, err 492 + return err 482 493 } 483 494 } 484 495 if values.LLM.FallbackProfiles != nil { ··· 486 497 } 487 498 488 499 if values.Multimodal != nil && values.Multimodal.ImageSources != nil { 489 - multimodalNode := ensureMappingValue(root, multimodalSettingsKey) 490 - imageNode := ensureMappingValue(multimodalNode, "image") 500 + multimodalNode := configbootstrap.EnsureMappingValue(root, multimodalSettingsKey) 501 + imageNode := configbootstrap.EnsureMappingValue(multimodalNode, "image") 491 502 setMappingStringList(imageNode, "sources", *values.Multimodal.ImageSources) 492 503 } 493 504 494 505 if values.Tools != nil { 495 - toolsNode := ensureMappingValue(root, toolsSettingsKey) 506 + toolsNode := configbootstrap.EnsureMappingValue(root, toolsSettingsKey) 496 507 if enabled := toolEnabledUpdateValue(values.Tools.WriteFile); enabled != nil { 497 - setMappingBoolPath(toolsNode, "write_file", "enabled", *enabled) 508 + configbootstrap.SetMappingBoolPath(toolsNode, "write_file", "enabled", *enabled) 498 509 } 499 510 if enabled := toolEnabledUpdateValue(values.Tools.Spawn); enabled != nil { 500 - setMappingBoolPath(toolsNode, "spawn", "enabled", *enabled) 511 + configbootstrap.SetMappingBoolPath(toolsNode, "spawn", "enabled", *enabled) 501 512 } 502 513 if enabled := toolEnabledUpdateValue(values.Tools.ContactsSend); enabled != nil { 503 - setMappingBoolPath(toolsNode, "contacts_send", "enabled", *enabled) 514 + configbootstrap.SetMappingBoolPath(toolsNode, "contacts_send", "enabled", *enabled) 504 515 } 505 516 if enabled := toolEnabledUpdateValue(values.Tools.TodoUpdate); enabled != nil { 506 - setMappingBoolPath(toolsNode, "todo_update", "enabled", *enabled) 517 + configbootstrap.SetMappingBoolPath(toolsNode, "todo_update", "enabled", *enabled) 507 518 } 508 519 if enabled := toolEnabledUpdateValue(values.Tools.PlanCreate); enabled != nil { 509 - setMappingBoolPath(toolsNode, "plan_create", "enabled", *enabled) 520 + configbootstrap.SetMappingBoolPath(toolsNode, "plan_create", "enabled", *enabled) 510 521 } 511 522 if enabled := toolEnabledUpdateValue(values.Tools.URLFetch); enabled != nil { 512 - setMappingBoolPath(toolsNode, "url_fetch", "enabled", *enabled) 523 + configbootstrap.SetMappingBoolPath(toolsNode, "url_fetch", "enabled", *enabled) 513 524 } 514 525 if enabled := toolEnabledUpdateValue(values.Tools.WebSearch); enabled != nil { 515 - setMappingBoolPath(toolsNode, "web_search", "enabled", *enabled) 526 + configbootstrap.SetMappingBoolPath(toolsNode, "web_search", "enabled", *enabled) 516 527 } 517 528 if enabled := toolEnabledUpdateValue(values.Tools.Bash); enabled != nil { 518 - setMappingBoolPath(toolsNode, "bash", "enabled", *enabled) 529 + configbootstrap.SetMappingBoolPath(toolsNode, "bash", "enabled", *enabled) 530 + } 531 + if enabled := toolEnabledUpdateValue(values.Tools.PowerShell); enabled != nil { 532 + configbootstrap.SetMappingBoolPath(toolsNode, "powershell", "enabled", *enabled) 519 533 } 520 534 } 521 - 522 - return marshalYAMLDocument(doc) 535 + return nil 523 536 } 524 537 525 538 func validateAgentConfigDocument(data []byte, effectiveLLM llmSettingsPayload) (*viper.Viper, error) { ··· 1088 1101 return 1089 1102 } 1090 1103 if update.Provider != nil { 1091 - setOrDeleteMappingScalar(node, "provider", *update.Provider) 1104 + configbootstrap.SetOrDeleteMappingScalar(node, "provider", *update.Provider) 1092 1105 } 1093 1106 if update.Endpoint != nil { 1094 - setOrDeleteMappingScalar(node, "endpoint", *update.Endpoint) 1107 + configbootstrap.SetOrDeleteMappingScalar(node, "endpoint", *update.Endpoint) 1095 1108 } 1096 1109 if update.Model != nil { 1097 - setOrDeleteMappingScalar(node, "model", *update.Model) 1110 + configbootstrap.SetOrDeleteMappingScalar(node, "model", *update.Model) 1098 1111 } 1099 1112 if update.ReasoningEffort != nil { 1100 - setOrDeleteMappingScalar(node, "reasoning_effort", *update.ReasoningEffort) 1113 + configbootstrap.SetOrDeleteMappingScalar(node, "reasoning_effort", *update.ReasoningEffort) 1101 1114 } 1102 1115 if update.ToolsEmulationMode != nil { 1103 - setOrDeleteMappingScalar(node, "tools_emulation_mode", *update.ToolsEmulationMode) 1116 + configbootstrap.SetOrDeleteMappingScalar(node, "tools_emulation_mode", *update.ToolsEmulationMode) 1104 1117 } 1105 1118 if strings.EqualFold(strings.TrimSpace(effective.Provider), "cloudflare") { 1106 - setOrDeleteMappingScalar(node, "api_key", "") 1107 - cloudflareNode := findMappingValue(node, "cloudflare") 1119 + configbootstrap.SetOrDeleteMappingScalar(node, "api_key", "") 1120 + cloudflareNode := configbootstrap.FindMappingValue(node, "cloudflare") 1108 1121 if cloudflareNode != nil && cloudflareNode.Kind != yaml.MappingNode { 1109 - cloudflareNode = ensureMappingValue(node, "cloudflare") 1122 + cloudflareNode = configbootstrap.EnsureMappingValue(node, "cloudflare") 1110 1123 } 1111 1124 if update.CloudflareAccountID != nil || update.CloudflareAPIToken != nil { 1112 1125 if cloudflareNode == nil { 1113 - cloudflareNode = ensureMappingValue(node, "cloudflare") 1126 + cloudflareNode = configbootstrap.EnsureMappingValue(node, "cloudflare") 1114 1127 } 1115 1128 if update.CloudflareAccountID != nil { 1116 - setOrDeleteMappingScalar(cloudflareNode, "account_id", *update.CloudflareAccountID) 1129 + configbootstrap.SetOrDeleteMappingScalar(cloudflareNode, "account_id", *update.CloudflareAccountID) 1117 1130 } 1118 1131 if update.CloudflareAPIToken != nil { 1119 - setOrDeleteMappingScalar(cloudflareNode, "api_token", *update.CloudflareAPIToken) 1132 + configbootstrap.SetOrDeleteMappingScalar(cloudflareNode, "api_token", *update.CloudflareAPIToken) 1120 1133 } 1121 1134 } 1122 1135 if cloudflareNode != nil && len(cloudflareNode.Content) == 0 { 1123 - deleteMappingKey(node, "cloudflare") 1136 + configbootstrap.DeleteMappingKey(node, "cloudflare") 1124 1137 } 1125 1138 return 1126 1139 } 1127 1140 if update.APIKey != nil { 1128 - setOrDeleteMappingScalar(node, "api_key", *update.APIKey) 1141 + configbootstrap.SetOrDeleteMappingScalar(node, "api_key", *update.APIKey) 1129 1142 } 1130 - deleteMappingKey(node, "cloudflare") 1143 + configbootstrap.DeleteMappingKey(node, "cloudflare") 1131 1144 } 1132 1145 1133 1146 func setLLMProfilesNode(llmNode *yaml.Node, profiles []llmProfileSettingsPayload, defaultProvider string) error { ··· 1135 1148 return nil 1136 1149 } 1137 1150 if len(profiles) == 0 { 1138 - deleteMappingKey(llmNode, "profiles") 1151 + configbootstrap.DeleteMappingKey(llmNode, "profiles") 1139 1152 return nil 1140 1153 } 1141 - existingProfiles := findMappingValue(llmNode, "profiles") 1154 + existingProfiles := configbootstrap.FindMappingValue(llmNode, "profiles") 1142 1155 existingNodes := make(map[string]*yaml.Node, len(profiles)) 1143 1156 if existingProfiles != nil && existingProfiles.Kind == yaml.MappingNode { 1144 1157 for i := 0; i+1 < len(existingProfiles.Content); i += 2 { ··· 1187 1200 } 1188 1201 values = normalizeNamedProfileSequence(values) 1189 1202 if len(values) == 0 { 1190 - deleteMappingKey(node, key) 1203 + configbootstrap.DeleteMappingKey(node, key) 1191 1204 return 1192 1205 } 1193 1206 list := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} ··· 1212 1225 return 1213 1226 } 1214 1227 values = normalizeNamedProfileSequence(values) 1215 - deleteMappingKey(llmNode, "fallback_profiles") 1228 + configbootstrap.DeleteMappingKey(llmNode, "fallback_profiles") 1216 1229 1217 - routesNode := findMappingValue(llmNode, "routes") 1230 + routesNode := configbootstrap.FindMappingValue(llmNode, "routes") 1218 1231 if len(values) == 0 { 1219 1232 pruneMainLoopFallbackProfilesNode(llmNode, routesNode) 1220 1233 return 1221 1234 } 1222 1235 if routesNode == nil || routesNode.Kind != yaml.MappingNode { 1223 - routesNode = ensureMappingValue(llmNode, "routes") 1236 + routesNode = configbootstrap.EnsureMappingValue(llmNode, "routes") 1224 1237 } 1225 1238 mainLoopNode := ensureRoutePolicyMappingValue(routesNode, llmutil.RoutePurposeMainLoop) 1226 1239 if mainLoopNode == nil { ··· 1236 1249 if routesNode == nil || routesNode.Kind != yaml.MappingNode { 1237 1250 return 1238 1251 } 1239 - mainLoopNode := findMappingValue(routesNode, llmutil.RoutePurposeMainLoop) 1252 + mainLoopNode := configbootstrap.FindMappingValue(routesNode, llmutil.RoutePurposeMainLoop) 1240 1253 if mainLoopNode == nil || mainLoopNode.Kind != yaml.MappingNode { 1241 1254 return 1242 1255 } 1243 - deleteMappingKey(mainLoopNode, "fallback_profiles") 1256 + configbootstrap.DeleteMappingKey(mainLoopNode, "fallback_profiles") 1244 1257 if len(mainLoopNode.Content) == 0 { 1245 - deleteMappingKey(routesNode, llmutil.RoutePurposeMainLoop) 1258 + configbootstrap.DeleteMappingKey(routesNode, llmutil.RoutePurposeMainLoop) 1246 1259 } 1247 1260 if len(routesNode.Content) == 0 { 1248 - deleteMappingKey(llmNode, "routes") 1261 + configbootstrap.DeleteMappingKey(llmNode, "routes") 1249 1262 } 1250 1263 } 1251 1264 ··· 1253 1266 if node == nil || node.Kind != yaml.MappingNode { 1254 1267 return nil 1255 1268 } 1256 - if value := findMappingValue(node, key); value != nil { 1269 + if value := configbootstrap.FindMappingValue(node, key); value != nil { 1257 1270 if value.Kind == yaml.MappingNode { 1258 1271 return value 1259 1272 } ··· 1456 1469 }() 1457 1470 client = inspectors.Wrap(client, route) 1458 1471 1459 - result := llmbench.Run(ctx, client, llmbench.ProfileMetadata{ 1472 + return agentSettingsTestResult{ 1460 1473 Provider: route.ClientConfig.Provider, 1461 1474 APIBase: strings.TrimSpace(route.ClientConfig.Endpoint), 1462 1475 Model: route.ClientConfig.Model, 1463 - }) 1464 - return agentSettingsTestResult{ 1465 - Provider: result.Provider, 1466 - APIBase: result.APIBase, 1467 - Model: result.Model, 1468 - Benchmarks: result.Benchmarks, 1476 + Benchmarks: llmbench.Run(ctx, client, llmbench.ProfileMetadata{ 1477 + Provider: route.ClientConfig.Provider, 1478 + APIBase: strings.TrimSpace(route.ClientConfig.Endpoint), 1479 + Model: route.ClientConfig.Model, 1480 + }).Benchmarks, 1469 1481 }, nil 1470 1482 } 1471 1483 ··· 1604 1616 data, err := os.ReadFile(configPath) 1605 1617 if err != nil { 1606 1618 if os.IsNotExist(err) { 1607 - return newEmptyYAMLDocument(), nil 1619 + return configbootstrap.NewEmptyDocument(), nil 1608 1620 } 1609 1621 return nil, err 1610 1622 } 1611 1623 if len(bytes.TrimSpace(data)) == 0 { 1612 - return newEmptyYAMLDocument(), nil 1624 + return configbootstrap.NewEmptyDocument(), nil 1613 1625 } 1614 - return loadYAMLDocumentBytes(data) 1615 - } 1616 - 1617 - func loadYAMLDocumentBytes(data []byte) (*yaml.Node, error) { 1618 - if len(bytes.TrimSpace(data)) == 0 { 1619 - return newEmptyYAMLDocument(), nil 1620 - } 1621 - var doc yaml.Node 1622 - if err := yaml.Unmarshal(data, &doc); err != nil { 1623 - return nil, fmt.Errorf("invalid config yaml: %w", err) 1624 - } 1625 - if len(doc.Content) == 0 { 1626 - doc.Content = []*yaml.Node{{ 1627 - Kind: yaml.MappingNode, 1628 - Tag: "!!map", 1629 - }} 1630 - } 1631 - return &doc, nil 1632 - } 1633 - 1634 - func newEmptyYAMLDocument() *yaml.Node { 1635 - return &yaml.Node{ 1636 - Kind: yaml.DocumentNode, 1637 - Content: []*yaml.Node{{ 1638 - Kind: yaml.MappingNode, 1639 - Tag: "!!map", 1640 - }}, 1641 - } 1626 + return configbootstrap.LoadDocumentBytes(data) 1642 1627 } 1643 1628 1644 1629 func readExpandedConsoleConfig(v *viper.Viper, configPath string) error { ··· 1652 1637 return err != nil && strings.Contains(strings.ToLower(err.Error()), "invalid config yaml") 1653 1638 } 1654 1639 1655 - func documentMapping(doc *yaml.Node) (*yaml.Node, error) { 1656 - if doc == nil { 1657 - return nil, fmt.Errorf("config document is nil") 1658 - } 1659 - if doc.Kind == yaml.DocumentNode { 1660 - if len(doc.Content) == 0 { 1661 - doc.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}} 1662 - } 1663 - doc = doc.Content[0] 1664 - } 1665 - if doc.Kind != yaml.MappingNode { 1666 - return nil, fmt.Errorf("config root must be a yaml mapping") 1667 - } 1668 - return doc, nil 1669 - } 1670 - 1671 - func marshalYAMLDocument(doc *yaml.Node) ([]byte, error) { 1672 - var buf bytes.Buffer 1673 - enc := yaml.NewEncoder(&buf) 1674 - enc.SetIndent(2) 1675 - if err := enc.Encode(doc); err != nil { 1676 - _ = enc.Close() 1677 - return nil, err 1678 - } 1679 - if err := enc.Close(); err != nil { 1680 - return nil, err 1681 - } 1682 - return buf.Bytes(), nil 1683 - } 1684 - 1685 - func findMappingValue(node *yaml.Node, key string) *yaml.Node { 1686 - if node == nil || node.Kind != yaml.MappingNode { 1687 - return nil 1688 - } 1689 - for i := 0; i+1 < len(node.Content); i += 2 { 1690 - if strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 1691 - return node.Content[i+1] 1692 - } 1693 - } 1694 - return nil 1695 - } 1696 - 1697 - func ensureMappingValue(node *yaml.Node, key string) *yaml.Node { 1698 - if node == nil || node.Kind != yaml.MappingNode { 1699 - return nil 1700 - } 1701 - if value := findMappingValue(node, key); value != nil { 1702 - if value.Kind == yaml.MappingNode { 1703 - return value 1704 - } 1705 - *value = yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} 1706 - return value 1707 - } 1708 - child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} 1709 - node.Content = append(node.Content, 1710 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 1711 - child, 1712 - ) 1713 - return child 1714 - } 1715 - 1716 - func setOrDeleteMappingScalar(node *yaml.Node, key, value string) { 1717 - if node == nil || node.Kind != yaml.MappingNode { 1718 - return 1719 - } 1720 - value = strings.TrimSpace(value) 1721 - for i := 0; i+1 < len(node.Content); i += 2 { 1722 - if !strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 1723 - continue 1724 - } 1725 - if value == "" { 1726 - node.Content = append(node.Content[:i], node.Content[i+2:]...) 1727 - return 1728 - } 1729 - node.Content[i+1] = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} 1730 - return 1731 - } 1732 - if value == "" { 1733 - return 1734 - } 1735 - node.Content = append(node.Content, 1736 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 1737 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, 1738 - ) 1739 - } 1740 - 1741 - func deleteMappingKey(node *yaml.Node, key string) { 1742 - if node == nil || node.Kind != yaml.MappingNode { 1743 - return 1744 - } 1745 - for i := 0; i+1 < len(node.Content); i += 2 { 1746 - if !strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 1747 - continue 1748 - } 1749 - node.Content = append(node.Content[:i], node.Content[i+2:]...) 1750 - return 1751 - } 1752 - } 1753 - 1754 - func setMappingBoolPath(node *yaml.Node, section, key string, value bool) { 1755 - sectionNode := ensureMappingValue(node, section) 1756 - if sectionNode == nil { 1757 - return 1758 - } 1759 - for i := 0; i+1 < len(sectionNode.Content); i += 2 { 1760 - if !strings.EqualFold(strings.TrimSpace(sectionNode.Content[i].Value), key) { 1761 - continue 1762 - } 1763 - sectionNode.Content[i+1] = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: boolString(value)} 1764 - return 1765 - } 1766 - sectionNode.Content = append(sectionNode.Content, 1767 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 1768 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: boolString(value)}, 1769 - ) 1770 - } 1771 - 1772 1640 func setMappingStringList(node *yaml.Node, key string, values []string) { 1773 - if node == nil || node.Kind != yaml.MappingNode { 1774 - return 1775 - } 1776 - list := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} 1777 1641 seen := make(map[string]struct{}, len(values)) 1642 + normalized := make([]string, 0, len(values)) 1778 1643 for _, raw := range values { 1779 1644 value := strings.TrimSpace(strings.ToLower(raw)) 1780 1645 if value == "" { ··· 1784 1649 continue 1785 1650 } 1786 1651 seen[value] = struct{}{} 1787 - list.Content = append(list.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}) 1652 + normalized = append(normalized, value) 1788 1653 } 1789 - for i := 0; i+1 < len(node.Content); i += 2 { 1790 - if !strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 1791 - continue 1792 - } 1793 - node.Content[i+1] = list 1794 - return 1795 - } 1796 - node.Content = append(node.Content, 1797 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 1798 - list, 1799 - ) 1800 - } 1801 - 1802 - func boolString(value bool) string { 1803 - if value { 1804 - return "true" 1805 - } 1806 - return "false" 1654 + configbootstrap.SetMappingStringList(node, key, normalized) 1807 1655 } 1808 1656 1809 1657 func firstNonEmpty(values ...string) string { ··· 1867 1715 if len(profiles) == 0 { 1868 1716 return profiles, nil 1869 1717 } 1870 - profilesNode := findMappingValue(llmNode, "profiles") 1718 + profilesNode := configbootstrap.FindMappingValue(llmNode, "profiles") 1871 1719 out := append([]llmProfileSettingsPayload(nil), profiles...) 1872 1720 envManaged := map[string]map[string]agentSettingsEnvManagedField{} 1873 1721 for i := range out { ··· 1875 1723 if name == "" { 1876 1724 continue 1877 1725 } 1878 - profileNode := findMappingValue(profilesNode, name) 1726 + profileNode := configbootstrap.FindMappingValue(profilesNode, name) 1879 1727 profileProvider := firstNonEmpty(strings.TrimSpace(out[i].Provider), defaultProvider) 1880 1728 fields := applyAgentSettingsYAMLEnvManaged( 1881 1729 &out[i].llmConfigFieldsPayload, ··· 1997 1845 for _, path := range fieldPathSets { 1998 1846 current := node 1999 1847 for _, key := range path { 2000 - current = findMappingValue(current, key) 1848 + current = configbootstrap.FindMappingValue(current, key) 2001 1849 if current == nil { 2002 1850 break 2003 1851 } ··· 2039 1887 } 2040 1888 2041 1889 func agentSettingsYAMLLLMNode(doc *yaml.Node) *yaml.Node { 2042 - root, err := documentMapping(doc) 1890 + root, err := configbootstrap.DocumentMapping(doc) 2043 1891 if err != nil { 2044 1892 return nil 2045 1893 } 2046 - return findMappingValue(root, llmSettingsKey) 1894 + return configbootstrap.FindMappingValue(root, llmSettingsKey) 2047 1895 } 2048 1896 2049 1897 func sortAgentSettingsProfilesByYAMLOrder(profiles []llmProfileSettingsPayload, doc *yaml.Node) []llmProfileSettingsPayload { ··· 2079 1927 } 2080 1928 2081 1929 func agentSettingsYAMLProfileOrder(doc *yaml.Node) []string { 2082 - root, err := documentMapping(doc) 1930 + root, err := configbootstrap.DocumentMapping(doc) 2083 1931 if err != nil { 2084 1932 return nil 2085 1933 } 2086 - llmNode := findMappingValue(root, llmSettingsKey) 1934 + llmNode := configbootstrap.FindMappingValue(root, llmSettingsKey) 2087 1935 if llmNode == nil || llmNode.Kind != yaml.MappingNode { 2088 1936 return nil 2089 1937 } 2090 - profilesNode := findMappingValue(llmNode, "profiles") 1938 + profilesNode := configbootstrap.FindMappingValue(llmNode, "profiles") 2091 1939 if profilesNode == nil || profilesNode.Kind != yaml.MappingNode { 2092 1940 return nil 2093 1941 } ··· 2101 1949 } 2102 1950 2103 1951 func agentSettingsYAMLHasLLMKey(doc *yaml.Node, key string) bool { 2104 - root, err := documentMapping(doc) 1952 + root, err := configbootstrap.DocumentMapping(doc) 2105 1953 if err != nil { 2106 1954 return false 2107 1955 } 2108 - llmNode := findMappingValue(root, llmSettingsKey) 1956 + llmNode := configbootstrap.FindMappingValue(root, llmSettingsKey) 2109 1957 if llmNode == nil || llmNode.Kind != yaml.MappingNode { 2110 1958 return false 2111 1959 } 2112 - return findMappingValue(llmNode, key) != nil 1960 + return configbootstrap.FindMappingValue(llmNode, key) != nil 2113 1961 } 2114 1962 2115 1963 func readAgentSettingsFromReader(r interface { ··· 2135 1983 URLFetch: toolEnabledPayload{Enabled: r.GetBool("tools.url_fetch.enabled")}, 2136 1984 WebSearch: toolEnabledPayload{Enabled: r.GetBool("tools.web_search.enabled")}, 2137 1985 Bash: toolEnabledPayload{Enabled: r.GetBool("tools.bash.enabled")}, 1986 + PowerShell: toolEnabledPayload{Enabled: r.GetBool("tools.powershell.enabled")}, 2138 1987 }, 2139 1988 } 2140 1989 }
+12 -2
cmd/mistermorph/consolecmd/agent_settings_test.go
··· 21 21 if err := os.WriteFile(configPath, []byte( 22 22 "llm:\n provider: cloudflare\n model: gpt-5.2\n reasoning_effort: high\n api_key: legacy-cf-token\n cloudflare:\n account_id: acc-123\n profiles:\n cheap:\n model: gpt-4.1-mini\n burst:\n provider: openai\n api_key: sk-profile\n model: gpt-4.1\n routes:\n main_loop:\n fallback_profiles:\n - cheap\n - burst\n"+ 23 23 "multimodal:\n image:\n sources: [telegram, line]\n"+ 24 - "tools:\n bash:\n enabled: false\n", 24 + "tools:\n bash:\n enabled: false\n powershell:\n enabled: true\n", 25 25 ), 0o600); err != nil { 26 26 t.Fatalf("WriteFile() error = %v", err) 27 27 } ··· 63 63 } 64 64 if got.Tools.Bash.Enabled { 65 65 t.Fatalf("got.Tools.Bash.Enabled = true, want false") 66 + } 67 + if !got.Tools.PowerShell.Enabled { 68 + t.Fatalf("got.Tools.PowerShell.Enabled = false, want true") 66 69 } 67 70 } 68 71 ··· 169 172 "plan_create":{"enabled":false}, 170 173 "url_fetch":{"enabled":true}, 171 174 "web_search":{"enabled":false}, 172 - "bash":{"enabled":false} 175 + "bash":{"enabled":false}, 176 + "powershell":{"enabled":true} 173 177 } 174 178 }`) 175 179 req := httptest.NewRequest(http.MethodPut, "/api/settings/agent", body) ··· 201 205 } 202 206 if !viper.GetBool("tools.spawn.enabled") { 203 207 t.Fatalf("viper tools.spawn.enabled = false, want true") 208 + } 209 + if !viper.GetBool("tools.powershell.enabled") { 210 + t.Fatalf("viper tools.powershell.enabled = false, want true") 204 211 } 205 212 206 213 var payload struct { ··· 229 236 } 230 237 if !payload.Tools.Spawn.Enabled { 231 238 t.Fatalf("payload.Tools.Spawn.Enabled = false, want true") 239 + } 240 + if !payload.Tools.PowerShell.Enabled { 241 + t.Fatalf("payload.Tools.PowerShell.Enabled = false, want true") 232 242 } 233 243 } 234 244
+30 -46
cmd/mistermorph/consolecmd/console_settings.go
··· 11 11 12 12 "github.com/quailyquaily/mistermorph/integration" 13 13 "github.com/quailyquaily/mistermorph/internal/channelopts" 14 + "github.com/quailyquaily/mistermorph/internal/configbootstrap" 14 15 "github.com/spf13/viper" 15 16 "gopkg.in/yaml.v3" 16 17 ) ··· 143 144 writeError(w, http.StatusInternalServerError, err.Error()) 144 145 return 145 146 } 146 - doc := newEmptyYAMLDocument() 147 + doc := configbootstrap.NewEmptyDocument() 147 148 if raw, readErr := os.ReadFile(configPath); readErr == nil && len(bytes.TrimSpace(raw)) > 0 { 148 - doc, err = loadYAMLDocumentBytes(raw) 149 + doc, err = configbootstrap.LoadDocumentBytes(raw) 149 150 if err != nil { 150 151 writeError(w, http.StatusInternalServerError, err.Error()) 151 152 return ··· 237 238 } 238 239 } 239 240 240 - doc, docErr := loadYAMLDocumentBytes(serialized) 241 + doc, docErr := configbootstrap.LoadDocumentBytes(serialized) 241 242 if docErr != nil { 242 243 writeError(w, http.StatusInternalServerError, docErr.Error()) 243 244 return ··· 294 295 if err != nil { 295 296 return nil, err 296 297 } 297 - root, err := documentMapping(doc) 298 + root, err := configbootstrap.DocumentMapping(doc) 298 299 if err != nil { 299 300 return nil, err 300 301 } 301 - consoleNode := ensureMappingValue(root, consoleSettingsKey) 302 + consoleNode := configbootstrap.EnsureMappingValue(root, consoleSettingsKey) 302 303 setMappingOrderedStringList(consoleNode, "managed_runtimes", values.ManagedRuntimes) 303 304 304 - telegramNode := ensureMappingValue(root, "telegram") 305 - setOrDeleteMappingScalar(telegramNode, "bot_token", strings.TrimSpace(values.Telegram.BotToken)) 305 + telegramNode := configbootstrap.EnsureMappingValue(root, "telegram") 306 + configbootstrap.SetOrDeleteMappingScalar(telegramNode, "bot_token", strings.TrimSpace(values.Telegram.BotToken)) 306 307 setMappingOrderedStringList(telegramNode, "allowed_chat_ids", normalizeConsoleStringList(values.Telegram.AllowedChatIDs)) 307 - setOrDeleteMappingScalar(telegramNode, "group_trigger_mode", strings.TrimSpace(values.Telegram.GroupTriggerMode)) 308 + configbootstrap.SetOrDeleteMappingScalar(telegramNode, "group_trigger_mode", strings.TrimSpace(values.Telegram.GroupTriggerMode)) 308 309 309 - slackNode := ensureMappingValue(root, "slack") 310 - setOrDeleteMappingScalar(slackNode, "bot_token", strings.TrimSpace(values.Slack.BotToken)) 311 - setOrDeleteMappingScalar(slackNode, "app_token", strings.TrimSpace(values.Slack.AppToken)) 310 + slackNode := configbootstrap.EnsureMappingValue(root, "slack") 311 + configbootstrap.SetOrDeleteMappingScalar(slackNode, "bot_token", strings.TrimSpace(values.Slack.BotToken)) 312 + configbootstrap.SetOrDeleteMappingScalar(slackNode, "app_token", strings.TrimSpace(values.Slack.AppToken)) 312 313 setMappingOrderedStringList(slackNode, "allowed_team_ids", normalizeConsoleStringList(values.Slack.AllowedTeamIDs)) 313 314 setMappingOrderedStringList(slackNode, "allowed_channel_ids", normalizeConsoleStringList(values.Slack.AllowedChannelIDs)) 314 - setOrDeleteMappingScalar(slackNode, "group_trigger_mode", strings.TrimSpace(values.Slack.GroupTriggerMode)) 315 + configbootstrap.SetOrDeleteMappingScalar(slackNode, "group_trigger_mode", strings.TrimSpace(values.Slack.GroupTriggerMode)) 315 316 316 - guardNode := ensureMappingValue(root, "guard") 317 - setMappingBoolValue(guardNode, "enabled", values.Guard.Enabled) 318 - networkNode := ensureMappingValue(guardNode, "network") 319 - urlFetchNode := ensureMappingValue(networkNode, "url_fetch") 317 + guardNode := configbootstrap.EnsureMappingValue(root, "guard") 318 + configbootstrap.SetMappingBoolValue(guardNode, "enabled", values.Guard.Enabled) 319 + networkNode := configbootstrap.EnsureMappingValue(guardNode, "network") 320 + urlFetchNode := configbootstrap.EnsureMappingValue(networkNode, "url_fetch") 320 321 setMappingOrderedStringList(urlFetchNode, "allowed_url_prefixes", normalizeConsoleStringList(values.Guard.Network.URLFetch.AllowedURLPrefixes)) 321 - setMappingBoolValue(urlFetchNode, "deny_private_ips", values.Guard.Network.URLFetch.DenyPrivateIPs) 322 - setMappingBoolValue(urlFetchNode, "follow_redirects", values.Guard.Network.URLFetch.FollowRedirects) 323 - setMappingBoolValue(urlFetchNode, "allow_proxy", values.Guard.Network.URLFetch.AllowProxy) 324 - redactionNode := ensureMappingValue(guardNode, "redaction") 325 - setMappingBoolValue(redactionNode, "enabled", values.Guard.Redaction.Enabled) 326 - approvalsNode := ensureMappingValue(guardNode, "approvals") 327 - setMappingBoolValue(approvalsNode, "enabled", values.Guard.Approvals.Enabled) 322 + configbootstrap.SetMappingBoolValue(urlFetchNode, "deny_private_ips", values.Guard.Network.URLFetch.DenyPrivateIPs) 323 + configbootstrap.SetMappingBoolValue(urlFetchNode, "follow_redirects", values.Guard.Network.URLFetch.FollowRedirects) 324 + configbootstrap.SetMappingBoolValue(urlFetchNode, "allow_proxy", values.Guard.Network.URLFetch.AllowProxy) 325 + redactionNode := configbootstrap.EnsureMappingValue(guardNode, "redaction") 326 + configbootstrap.SetMappingBoolValue(redactionNode, "enabled", values.Guard.Redaction.Enabled) 327 + approvalsNode := configbootstrap.EnsureMappingValue(guardNode, "approvals") 328 + configbootstrap.SetMappingBoolValue(approvalsNode, "enabled", values.Guard.Approvals.Enabled) 328 329 329 - return marshalYAMLDocument(doc) 330 + return configbootstrap.MarshalDocument(doc) 330 331 } 331 332 332 333 func readConsoleSettingsFromReader(r interface { ··· 483 484 return normalizeConsoleSettingsPayload(next) 484 485 } 485 486 486 - func setMappingBoolValue(node *yaml.Node, key string, value bool) { 487 - if node == nil || node.Kind != yaml.MappingNode { 488 - return 489 - } 490 - for i := 0; i+1 < len(node.Content); i += 2 { 491 - if !strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 492 - continue 493 - } 494 - node.Content[i+1] = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: boolString(value)} 495 - return 496 - } 497 - node.Content = append(node.Content, 498 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 499 - &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: boolString(value)}, 500 - ) 501 - } 502 - 503 487 func normalizeConsoleStringList(values []string) []string { 504 488 if len(values) == 0 { 505 489 return nil ··· 540 524 doc *yaml.Node, 541 525 ) (consoleSettingsPayload, consoleSettingsEnvManagedPayload) { 542 526 envManaged := currentConsoleSettingsEnvManaged() 543 - root, _ := documentMapping(doc) 527 + root, _ := configbootstrap.DocumentMapping(doc) 544 528 settings.Telegram, envManaged.Telegram = buildConsoleTelegramSettingsResponseView( 545 529 settings.Telegram, 546 - findMappingValue(root, "telegram"), 530 + configbootstrap.FindMappingValue(root, "telegram"), 547 531 envManaged.Telegram, 548 532 ) 549 533 settings.Slack, envManaged.Slack = buildConsoleSlackSettingsResponseView( 550 534 settings.Slack, 551 - findMappingValue(root, "slack"), 535 + configbootstrap.FindMappingValue(root, "slack"), 552 536 envManaged.Slack, 553 537 ) 554 538 if len(envManaged.Telegram) == 0 { ··· 626 610 } 627 611 628 612 func consoleSettingsYAMLManagedField(node *yaml.Node, field string) (agentSettingsEnvManagedField, bool) { 629 - entryNode := findMappingValue(node, field) 613 + entryNode := configbootstrap.FindMappingValue(node, field) 630 614 if entryNode == nil || entryNode.Kind != yaml.ScalarNode { 631 615 return agentSettingsEnvManagedField{}, false 632 616 } ··· 654 638 } 655 639 656 640 func consoleSettingsShouldHideSensitiveField(node *yaml.Node, field string) bool { 657 - entryNode := findMappingValue(node, field) 641 + entryNode := configbootstrap.FindMappingValue(node, field) 658 642 if entryNode == nil || entryNode.Kind != yaml.ScalarNode { 659 643 return true 660 644 }
+17
cmd/mistermorph/consolecmd/runtime_support.go
··· 35 35 ToolsBashMaxOutputBytes int 36 36 ToolsBashDenyPaths []string 37 37 ToolsBashInjectedEnvVars []string 38 + ToolsPowerShellEnabled bool 39 + ToolsPowerShellTimeout time.Duration 40 + ToolsPowerShellMaxOutputBytes int 41 + ToolsPowerShellDenyPaths []string 42 + ToolsPowerShellInjectedEnvVars []string 38 43 ToolsURLFetchEnabled bool 39 44 ToolsURLFetchTimeout time.Duration 40 45 ToolsURLFetchMaxBytes int64 ··· 81 86 ToolsBashMaxOutputBytes: viper.GetInt("tools.bash.max_output_bytes"), 82 87 ToolsBashDenyPaths: append([]string(nil), viper.GetStringSlice("tools.bash.deny_paths")...), 83 88 ToolsBashInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.bash.injected_env_vars")...), 89 + ToolsPowerShellEnabled: viper.GetBool("tools.powershell.enabled"), 90 + ToolsPowerShellTimeout: viper.GetDuration("tools.powershell.timeout"), 91 + ToolsPowerShellMaxOutputBytes: viper.GetInt("tools.powershell.max_output_bytes"), 92 + ToolsPowerShellDenyPaths: append([]string(nil), viper.GetStringSlice("tools.powershell.deny_paths")...), 93 + ToolsPowerShellInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.powershell.injected_env_vars")...), 84 94 ToolsURLFetchEnabled: viper.GetBool("tools.url_fetch.enabled"), 85 95 ToolsURLFetchTimeout: viper.GetDuration("tools.url_fetch.timeout"), 86 96 ToolsURLFetchMaxBytes: viper.GetInt64("tools.url_fetch.max_bytes"), ··· 156 166 MaxOutputBytes: cfg.ToolsBashMaxOutputBytes, 157 167 DenyPaths: append([]string(nil), cfg.ToolsBashDenyPaths...), 158 168 InjectedEnvVars: append([]string(nil), cfg.ToolsBashInjectedEnvVars...), 169 + }, 170 + PowerShell: toolsutil.StaticPowerShellConfig{ 171 + Enabled: cfg.ToolsPowerShellEnabled, 172 + Timeout: cfg.ToolsPowerShellTimeout, 173 + MaxOutputBytes: cfg.ToolsPowerShellMaxOutputBytes, 174 + DenyPaths: append([]string(nil), cfg.ToolsPowerShellDenyPaths...), 175 + InjectedEnvVars: append([]string(nil), cfg.ToolsPowerShellInjectedEnvVars...), 159 176 }, 160 177 URLFetch: toolsutil.StaticURLFetchConfig{ 161 178 Enabled: cfg.ToolsURLFetchEnabled,
+110 -5
cmd/mistermorph/install.go
··· 8 8 9 9 "github.com/quailyquaily/mistermorph/assets" 10 10 "github.com/quailyquaily/mistermorph/internal/clifmt" 11 + "github.com/quailyquaily/mistermorph/internal/configbootstrap" 11 12 "github.com/quailyquaily/mistermorph/internal/pathutil" 12 13 "github.com/spf13/cobra" 13 14 "github.com/spf13/viper" ··· 89 90 if err != nil { 90 91 return "", err 91 92 } 92 - return patchInitConfigWithSetup(body, dir, cfgSetup), nil 93 + return patchInitConfigWithSetup(body, dir, cfgSetup) 93 94 }, 94 95 }, 95 96 { ··· 296 297 return string(data), nil 297 298 } 298 299 299 - func patchInitConfigWithSetup(cfg string, dir string, setup *installConfigSetup) string { 300 + func patchInitConfigWithSetup(cfg string, dir string, setup *installConfigSetup) (string, error) { 300 301 if strings.TrimSpace(cfg) == "" { 301 - return cfg 302 + return cfg, nil 302 303 } 303 304 dir = filepath.Clean(dir) 304 305 dir = filepath.ToSlash(dir) 305 - cfg = strings.ReplaceAll(cfg, `file_state_dir: "~/.morph"`, fmt.Sprintf(`file_state_dir: "%s"`, dir)) 306 - cfg = applyInstallConfigSetupOverrides(cfg, setup) 306 + rendered, err := configbootstrap.Apply([]byte(cfg), buildInstallBootstrapConfig(dir, setup)) 307 + if err != nil { 308 + return "", err 309 + } 310 + return string(rendered), nil 311 + } 312 + 313 + func buildInstallBootstrapConfig(dir string, setup *installConfigSetup) configbootstrap.Config { 314 + cfg := configbootstrap.Config{ 315 + FileStateDir: dir, 316 + LLM: configbootstrap.LLMConfig{ 317 + Provider: "openai", 318 + }, 319 + Console: &configbootstrap.ConsoleConfig{ 320 + ManagedKinds: []string{}, 321 + Endpoints: []configbootstrap.ConsoleEndpoint{}, 322 + }, 323 + } 324 + if setup == nil { 325 + return cfg 326 + } 327 + 328 + cfg.LLM.Provider = normalizeConfigProviderForSetup(setup.Provider, setup.Endpoint) 329 + cfg.LLM.Endpoint = strings.TrimSpace(setup.Endpoint) 330 + cfg.LLM.Model = strings.TrimSpace(setup.Model) 331 + switch cfg.LLM.Provider { 332 + case setupProviderCloudflare: 333 + cfg.LLM.CloudflareAccountID = strings.TrimSpace(setup.CloudflareAccount) 334 + cfg.LLM.CloudflareAPIToken = strings.TrimSpace(setup.CloudflareAPIToken) 335 + default: 336 + cfg.LLM.APIKey = strings.TrimSpace(setup.APIKey) 337 + } 338 + 339 + if !setup.ConfigureConsole { 340 + return cfg 341 + } 342 + 343 + consoleCfg := configbootstrap.ConsoleConfig{ 344 + Listen: normalizedInstallConsoleListen(setup.ConsoleListen), 345 + BasePath: normalizedInstallConsoleBasePath(setup.ConsoleBasePath), 346 + Password: strings.TrimSpace(setup.ConsolePassword), 347 + ManagedKinds: []string{}, 348 + Endpoints: []configbootstrap.ConsoleEndpoint{{ 349 + Name: normalizedInstallConsoleEndpointName(setup.ConsoleEndpointName), 350 + URL: normalizedInstallConsoleEndpointURL(setup.ConsoleEndpointURL), 351 + AuthToken: normalizedInstallConsoleEndpointTokenRef(setup), 352 + }}, 353 + } 354 + cfg.Console = &consoleCfg 355 + if tokenRef := normalizedInstallServerAuthTokenRef(setup.ServerAuthTokenEnv); tokenRef != "" { 356 + cfg.ServerAuthToken = tokenRef 357 + } 307 358 return cfg 308 359 } 360 + 361 + func normalizedInstallConsoleListen(raw string) string { 362 + v := strings.TrimSpace(raw) 363 + if v == "" { 364 + return "127.0.0.1:9080" 365 + } 366 + return v 367 + } 368 + 369 + func normalizedInstallConsoleBasePath(raw string) string { 370 + basePath, err := normalizeConsoleBasePath(raw) 371 + if err != nil { 372 + return "/" 373 + } 374 + return basePath 375 + } 376 + 377 + func normalizedInstallConsoleEndpointName(raw string) string { 378 + v := strings.TrimSpace(raw) 379 + if v == "" { 380 + return "Main Runtime" 381 + } 382 + return v 383 + } 384 + 385 + func normalizedInstallConsoleEndpointURL(raw string) string { 386 + v, err := normalizeConsoleEndpointURL(raw) 387 + if err != nil { 388 + return "http://127.0.0.1:8787" 389 + } 390 + return v 391 + } 392 + 393 + func normalizedInstallServerAuthTokenRef(raw string) string { 394 + envName := strings.TrimSpace(raw) 395 + if !isValidEnvVarName(envName) { 396 + return "" 397 + } 398 + return "${" + envName + "}" 399 + } 400 + 401 + func normalizedInstallConsoleEndpointTokenRef(setup *installConfigSetup) string { 402 + if setup == nil { 403 + return "${MISTER_MORPH_SERVER_AUTH_TOKEN}" 404 + } 405 + envName := strings.TrimSpace(setup.ConsoleEndpointAuthTokenEnv) 406 + if !isValidEnvVarName(envName) { 407 + envName = strings.TrimSpace(setup.ServerAuthTokenEnv) 408 + } 409 + if !isValidEnvVarName(envName) { 410 + envName = "MISTER_MORPH_SERVER_AUTH_TOKEN" 411 + } 412 + return "${" + envName + "}" 413 + }
-133
cmd/mistermorph/install_config_wizard.go
··· 401 401 return strings.TrimSpace(line), nil 402 402 } 403 403 404 - func applyInstallConfigSetupOverrides(cfg string, setup *installConfigSetup) string { 405 - if strings.TrimSpace(cfg) == "" { 406 - return cfg 407 - } 408 - if setup == nil { 409 - return removeConfigCloudflareBlock(cfg) 410 - } 411 - 412 - cfg = replaceConfigLine( 413 - cfg, 414 - " provider: openai", 415 - " provider: "+normalizeConfigProviderForSetup(setup.Provider, setup.Endpoint), 416 - ) 417 - cfg = replaceConfigLine(cfg, ` endpoint: "https://api.openai.com"`, ` endpoint: `+yamlQuotedScalar(setup.Endpoint)) 418 - cfg = replaceConfigLinePrefix(cfg, " model: ", ` model: `+yamlQuotedScalar(setup.Model)) 419 - 420 - apiKeyComment := " # or set via MISTER_MORPH_LLM_API_KEY" 421 - switch strings.ToLower(strings.TrimSpace(setup.Provider)) { 422 - case setupProviderCloudflare: 423 - cfg = replaceConfigLinePrefix(cfg, " api_key: ", ` api_key: ""`+apiKeyComment) 424 - cfg = replaceConfigLine(cfg, ` account_id: ""`, ` account_id: `+yamlQuotedScalar(setup.CloudflareAccount)) 425 - cfg = replaceConfigLinePrefix(cfg, " api_token: ", ` api_token: `+yamlQuotedScalar(setup.CloudflareAPIToken)) 426 - default: 427 - cfg = replaceConfigLinePrefix(cfg, " api_key: ", ` api_key: `+yamlQuotedScalar(setup.APIKey)+apiKeyComment) 428 - cfg = removeConfigCloudflareBlock(cfg) 429 - } 430 - 431 - return cfg 432 - } 433 - 434 - func applyServerAuthTokenSetupOverrides(cfg string, setup *installConfigSetup) string { 435 - if setup == nil { 436 - return cfg 437 - } 438 - envName := strings.TrimSpace(setup.ServerAuthTokenEnv) 439 - if !isValidEnvVarName(envName) { 440 - return cfg 441 - } 442 - return replaceConfigLinePrefix(cfg, " auth_token: ", ` auth_token: "${`+envName+`}"`) 443 - } 444 - 445 - func applyConsoleConfigSetupOverrides(cfg string, setup *installConfigSetup) string { 446 - if setup == nil || !setup.ConfigureConsole { 447 - return cfg 448 - } 449 - return replaceYAMLTopLevelBlock(cfg, "console", renderConsoleConfigSnippet(setup)) 450 - } 451 - 452 404 func renderConsoleConfigSnippet(setup *installConfigSetup) string { 453 405 listen := strings.TrimSpace(setup.ConsoleListen) 454 406 if listen == "" { ··· 501 453 " auth_token: " + yamlQuotedScalar(endpointTokenRef), 502 454 } 503 455 return strings.Join(lines, "\n") 504 - } 505 - 506 - func replaceYAMLTopLevelBlock(cfg string, key string, block string) string { 507 - cfg = strings.TrimSpace(cfg) 508 - if cfg == "" || strings.TrimSpace(key) == "" || strings.TrimSpace(block) == "" { 509 - return cfg 510 - } 511 - lines := strings.Split(cfg, "\n") 512 - start := -1 513 - target := strings.TrimSpace(key) + ":" 514 - for i, line := range lines { 515 - trimmed := strings.TrimSpace(line) 516 - if trimmed != target { 517 - continue 518 - } 519 - if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { 520 - continue 521 - } 522 - start = i 523 - break 524 - } 525 - if start < 0 { 526 - return cfg 527 - } 528 - 529 - end := len(lines) 530 - for i := start + 1; i < len(lines); i++ { 531 - line := lines[i] 532 - trimmed := strings.TrimSpace(line) 533 - if trimmed == "" { 534 - continue 535 - } 536 - if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { 537 - continue 538 - } 539 - if strings.HasSuffix(trimmed, ":") { 540 - end = i 541 - break 542 - } 543 - } 544 - 545 - blockLines := strings.Split(strings.TrimSpace(block), "\n") 546 - out := make([]string, 0, len(lines)-(end-start)+len(blockLines)) 547 - out = append(out, lines[:start]...) 548 - out = append(out, blockLines...) 549 - out = append(out, lines[end:]...) 550 - return strings.Join(out, "\n") + "\n" 551 - } 552 - 553 - func replaceConfigLine(cfg string, from string, to string) string { 554 - if strings.TrimSpace(cfg) == "" || strings.TrimSpace(from) == "" { 555 - return cfg 556 - } 557 - return strings.Replace(cfg, from, to, 1) 558 - } 559 - 560 - func replaceConfigLinePrefix(cfg string, prefix string, to string) string { 561 - if strings.TrimSpace(cfg) == "" || strings.TrimSpace(prefix) == "" { 562 - return cfg 563 - } 564 - lines := strings.Split(cfg, "\n") 565 - for i, line := range lines { 566 - if strings.HasPrefix(line, prefix) { 567 - lines[i] = to 568 - return strings.Join(lines, "\n") 569 - } 570 - } 571 - return cfg 572 - } 573 - 574 - func removeConfigCloudflareBlock(cfg string) string { 575 - withComment := strings.Join([]string{ 576 - " # Provider-specific settings for Cloudflare Workers AI.", 577 - " cloudflare:", 578 - ` account_id: ""`, 579 - ` api_token: "${CLOUDFLARE_API_TOKEN}"`, 580 - }, "\n") + "\n" 581 - withoutComment := strings.Join([]string{ 582 - " cloudflare:", 583 - ` account_id: ""`, 584 - ` api_token: "${CLOUDFLARE_API_TOKEN}"`, 585 - }, "\n") + "\n" 586 - cfg = strings.Replace(cfg, withComment, "", 1) 587 - cfg = strings.Replace(cfg, withoutComment, "", 1) 588 - return cfg 589 456 } 590 457 591 458 func yamlQuotedScalar(v string) string {
+95 -18
cmd/mistermorph/install_config_wizard_test.go
··· 9 9 "strings" 10 10 "testing" 11 11 12 + "github.com/quailyquaily/mistermorph/internal/platformutil" 12 13 "github.com/spf13/cobra" 13 14 "github.com/spf13/viper" 14 15 ) 16 + 17 + func loadPatchedConfig(t *testing.T, body string) *viper.Viper { 18 + t.Helper() 19 + tmp := viper.New() 20 + tmp.SetConfigType("yaml") 21 + if err := tmp.ReadConfig(strings.NewReader(body)); err != nil { 22 + t.Fatalf("ReadConfig() error = %v\nconfig:\n%s", err, body) 23 + } 24 + return tmp 25 + } 15 26 16 27 func TestFindReadableInstallConfigPriority(t *testing.T) { 17 28 initViperDefaults() ··· 104 115 CloudflareAPIToken: "token-xyz", 105 116 } 106 117 107 - got := patchInitConfigWithSetup(body, "/tmp/my-state", setup) 118 + got, err := patchInitConfigWithSetup(body, "/tmp/my-state", setup) 119 + if err != nil { 120 + t.Fatalf("patchInitConfigWithSetup() error = %v", err) 121 + } 108 122 109 - assertContains := func(substr string) { 110 - t.Helper() 111 - if !strings.Contains(got, substr) { 112 - t.Fatalf("patched config missing %q", substr) 113 - } 123 + cfg := loadPatchedConfig(t, got) 124 + 125 + if gotPath := cfg.GetString("file_state_dir"); gotPath != "/tmp/my-state" { 126 + t.Fatalf("file_state_dir = %q, want /tmp/my-state", gotPath) 127 + } 128 + if gotProvider := cfg.GetString("llm.provider"); gotProvider != "cloudflare" { 129 + t.Fatalf("llm.provider = %q, want cloudflare", gotProvider) 114 130 } 115 - 116 - assertContains(`file_state_dir: "/tmp/my-state"`) 117 - assertContains(`provider: cloudflare`) 118 - assertContains(`endpoint: "https://api.cloudflare.com/client/v4"`) 119 - assertContains(`model: "@cf/meta/llama-3.1-8b-instruct"`) 120 - assertContains(`api_key: "" # or set via MISTER_MORPH_LLM_API_KEY`) 121 - assertContains(`account_id: "acc-123"`) 122 - assertContains(`api_token: "token-xyz"`) 131 + if gotEndpoint := cfg.GetString("llm.endpoint"); gotEndpoint != "https://api.cloudflare.com/client/v4" { 132 + t.Fatalf("llm.endpoint = %q, want cloudflare endpoint", gotEndpoint) 133 + } 134 + if gotModel := cfg.GetString("llm.model"); gotModel != "@cf/meta/llama-3.1-8b-instruct" { 135 + t.Fatalf("llm.model = %q, want cloudflare model", gotModel) 136 + } 137 + if gotAccountID := cfg.GetString("llm.cloudflare.account_id"); gotAccountID != "acc-123" { 138 + t.Fatalf("llm.cloudflare.account_id = %q, want acc-123", gotAccountID) 139 + } 140 + if gotToken := cfg.GetString("llm.cloudflare.api_token"); gotToken != "token-xyz" { 141 + t.Fatalf("llm.cloudflare.api_token = %q, want token-xyz", gotToken) 142 + } 143 + if gotAPIKey := cfg.GetString("llm.api_key"); gotAPIKey != "" { 144 + t.Fatalf("llm.api_key = %q, want empty for cloudflare", gotAPIKey) 145 + } 146 + if gotSources := cfg.GetStringSlice("multimodal.image.sources"); len(gotSources) != 0 { 147 + t.Fatalf("multimodal.image.sources = %#v, want empty", gotSources) 148 + } 149 + var endpoints []map[string]any 150 + if err := cfg.UnmarshalKey("console.endpoints", &endpoints); err != nil { 151 + t.Fatalf("UnmarshalKey(console.endpoints) error = %v", err) 152 + } 153 + if len(endpoints) != 0 { 154 + t.Fatalf("console.endpoints = %#v, want empty", endpoints) 155 + } 123 156 if strings.Contains(got, "tg-token") || strings.Contains(got, "xoxb-test") || strings.Contains(got, "console-secret") { 124 157 t.Fatalf("patched config should not include removed onboarding integrations: %s", got) 125 158 } ··· 131 164 t.Fatalf("loadConfigExample() error = %v", err) 132 165 } 133 166 134 - got := patchInitConfigWithSetup(body, "/tmp/my-state", &installConfigSetup{ 167 + got, err := patchInitConfigWithSetup(body, "/tmp/my-state", &installConfigSetup{ 135 168 Provider: setupProviderOpenAICompatible, 136 169 Endpoint: "https://api.deepseek.com", 137 170 Model: "deepseek-chat", 138 171 APIKey: "sk-openai-compatible", 139 172 }) 173 + if err != nil { 174 + t.Fatalf("patchInitConfigWithSetup() error = %v", err) 175 + } 176 + cfg := loadPatchedConfig(t, got) 140 177 141 - if !strings.Contains(got, `provider: openai`) { 142 - t.Fatalf("patched config missing openai provider: %s", got) 178 + if gotProvider := cfg.GetString("llm.provider"); gotProvider != "openai" { 179 + t.Fatalf("llm.provider = %q, want openai", gotProvider) 143 180 } 144 181 if strings.Contains(got, "provider: openai_custom") { 145 182 t.Fatalf("patched config should not write openai_custom: %s", got) 146 183 } 184 + if gotEndpoint := cfg.GetString("llm.endpoint"); gotEndpoint != "https://api.deepseek.com" { 185 + t.Fatalf("llm.endpoint = %q, want https://api.deepseek.com", gotEndpoint) 186 + } 187 + if gotModel := cfg.GetString("llm.model"); gotModel != "deepseek-chat" { 188 + t.Fatalf("llm.model = %q, want deepseek-chat", gotModel) 189 + } 190 + if gotAPIKey := cfg.GetString("llm.api_key"); gotAPIKey != "sk-openai-compatible" { 191 + t.Fatalf("llm.api_key = %q, want sk-openai-compatible", gotAPIKey) 192 + } 147 193 if strings.Contains(got, "\n cloudflare:\n") || strings.Contains(got, "account_id:") || strings.Contains(got, "api_token:") { 148 194 t.Fatalf("patched config should not include cloudflare block: %s", got) 149 195 } ··· 155 201 t.Fatalf("loadConfigExample() error = %v", err) 156 202 } 157 203 158 - got := patchInitConfigWithSetup(body, "/tmp/my-state", nil) 204 + got, err := patchInitConfigWithSetup(body, "/tmp/my-state", nil) 205 + if err != nil { 206 + t.Fatalf("patchInitConfigWithSetup() error = %v", err) 207 + } 208 + cfg := loadPatchedConfig(t, got) 159 209 160 210 if strings.Contains(got, "\n cloudflare:\n") || strings.Contains(got, "account_id:") || strings.Contains(got, "api_token:") { 161 211 t.Fatalf("default patched config should not include cloudflare block: %s", got) 212 + } 213 + if strings.Contains(got, "\n endpoint: \"https://api.openai.com\"") || 214 + strings.Contains(got, "\n model: \"gpt-5.4\"") || 215 + strings.Contains(got, "\n api_key: \"${OPENAI_API_KEY}\"") { 216 + t.Fatalf("default patched config should clear template llm examples: %s", got) 217 + } 218 + if gotProvider := cfg.GetString("llm.provider"); gotProvider != "openai" { 219 + t.Fatalf("llm.provider = %q, want openai", gotProvider) 220 + } 221 + var endpoints []map[string]any 222 + if err := cfg.UnmarshalKey("console.endpoints", &endpoints); err != nil { 223 + t.Fatalf("UnmarshalKey(console.endpoints) error = %v", err) 224 + } 225 + if len(endpoints) != 0 { 226 + t.Fatalf("console.endpoints = %#v, want empty", endpoints) 227 + } 228 + expectedBash := true 229 + expectedPowerShell := false 230 + if platformutil.IsWindows() { 231 + expectedBash = false 232 + expectedPowerShell = true 233 + } 234 + if gotBash := cfg.GetBool("tools.bash.enabled"); gotBash != expectedBash { 235 + t.Fatalf("tools.bash.enabled = %v, want %v", gotBash, expectedBash) 236 + } 237 + if gotPowerShell := cfg.GetBool("tools.powershell.enabled"); gotPowerShell != expectedPowerShell { 238 + t.Fatalf("tools.powershell.enabled = %v, want %v", gotPowerShell, expectedPowerShell) 162 239 } 163 240 } 164 241
+17
cmd/mistermorph/registry.go
··· 30 30 ToolsBashMaxOutputBytes int 31 31 ToolsBashDenyPaths []string 32 32 ToolsBashInjectedEnvVars []string 33 + ToolsPowerShellEnabled bool 34 + ToolsPowerShellTimeout time.Duration 35 + ToolsPowerShellMaxOutputBytes int 36 + ToolsPowerShellDenyPaths []string 37 + ToolsPowerShellInjectedEnvVars []string 33 38 ToolsURLFetchEnabled bool 34 39 ToolsURLFetchTimeout time.Duration 35 40 ToolsURLFetchMaxBytes int64 ··· 79 84 ToolsBashMaxOutputBytes: viper.GetInt("tools.bash.max_output_bytes"), 80 85 ToolsBashDenyPaths: append([]string(nil), viper.GetStringSlice("tools.bash.deny_paths")...), 81 86 ToolsBashInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.bash.injected_env_vars")...), 87 + ToolsPowerShellEnabled: viper.GetBool("tools.powershell.enabled"), 88 + ToolsPowerShellTimeout: viper.GetDuration("tools.powershell.timeout"), 89 + ToolsPowerShellMaxOutputBytes: viper.GetInt("tools.powershell.max_output_bytes"), 90 + ToolsPowerShellDenyPaths: append([]string(nil), viper.GetStringSlice("tools.powershell.deny_paths")...), 91 + ToolsPowerShellInjectedEnvVars: append([]string(nil), viper.GetStringSlice("tools.powershell.injected_env_vars")...), 82 92 ToolsURLFetchEnabled: viper.GetBool("tools.url_fetch.enabled"), 83 93 ToolsURLFetchTimeout: viper.GetDuration("tools.url_fetch.timeout"), 84 94 ToolsURLFetchMaxBytes: viper.GetInt64("tools.url_fetch.max_bytes"), ··· 157 167 MaxOutputBytes: cfg.ToolsBashMaxOutputBytes, 158 168 DenyPaths: append([]string(nil), cfg.ToolsBashDenyPaths...), 159 169 InjectedEnvVars: append([]string(nil), cfg.ToolsBashInjectedEnvVars...), 170 + }, 171 + PowerShell: toolsutil.StaticPowerShellConfig{ 172 + Enabled: cfg.ToolsPowerShellEnabled, 173 + Timeout: cfg.ToolsPowerShellTimeout, 174 + MaxOutputBytes: cfg.ToolsPowerShellMaxOutputBytes, 175 + DenyPaths: append([]string(nil), cfg.ToolsPowerShellDenyPaths...), 176 + InjectedEnvVars: append([]string(nil), cfg.ToolsPowerShellInjectedEnvVars...), 160 177 }, 161 178 URLFetch: toolsutil.StaticURLFetchConfig{ 162 179 Enabled: cfg.ToolsURLFetchEnabled,
+134
docs/feat/feat_20260418_config_defaults_sources.md
··· 1 + --- 2 + date: 2026-04-18 3 + title: Config Defaults Sources 4 + status: draft 5 + --- 6 + 7 + # Config Defaults Sources 8 + 9 + ## 1) Goal 10 + 11 + This note explains where configuration defaults come from today, why there are two defaulting entrypoints, and where each one is used. 12 + 13 + This is intentionally documented outside the current PowerShell PR scope. 14 + 15 + ## 2) Current Defaulting Entrypoints 16 + 17 + There are currently two functions that apply defaults to a `viper.Viper` instance: 18 + 19 + - `internal/configdefaults.Apply` 20 + - `integration.ApplyViperDefaults` 21 + 22 + They overlap heavily. 23 + 24 + Both set defaults for common runtime and tool keys such as: 25 + 26 + - `llm.*` 27 + - agent limits like `max_steps` 28 + - file state/cache directories 29 + - `tools.read_file.*` 30 + - `tools.write_file.*` 31 + - `tools.bash.*` 32 + - `tools.powershell.*` 33 + - `tools.url_fetch.*` 34 + - `tools.web_search.*` 35 + 36 + ## 3) `internal/configdefaults.Apply` 37 + 38 + This is the main program default source for the global CLI/runtime `viper`. 39 + 40 + Primary uses: 41 + 42 + - CLI initialization through `initConfig -> initViperDefaults` 43 + - main registry loading from the global `viper` 44 + 45 + Relevant code: 46 + 47 + - `cmd/mistermorph/defaults.go` 48 + - `cmd/mistermorph/root.go` 49 + - `cmd/mistermorph/registry.go` 50 + 51 + Operationally, this is the defaulting path for the normal application runtime: 52 + 53 + - `run` 54 + - `console` 55 + - `telegram` 56 + - `slack` 57 + - `line` 58 + - `lark` 59 + 60 + as long as they are reading from the process-wide `viper`. 61 + 62 + ## 4) `integration.ApplyViperDefaults` 63 + 64 + This is used when code creates a fresh temporary `viper` instance and wants a self-contained config reader. 65 + 66 + Primary uses: 67 + 68 + - `integration` runtime snapshot loading 69 + - Console Settings read/default/expand flows 70 + - Agent Settings read/default/validate flows 71 + - setup repair config reload 72 + 73 + Relevant code: 74 + 75 + - `integration/runtime_snapshot_loader.go` 76 + - `cmd/mistermorph/consolecmd/console_settings.go` 77 + - `cmd/mistermorph/consolecmd/agent_settings.go` 78 + - `cmd/mistermorph/consolecmd/setup_repair.go` 79 + 80 + Operationally, this path is mostly for: 81 + 82 + - local config editing 83 + - preview/default payload generation 84 + - config validation against a temporary reader 85 + - integration runtime bootstrapping from explicit overrides 86 + 87 + ## 5) Why Two Sources Exist 88 + 89 + From a first-principles view, the two functions solve different construction modes: 90 + 91 + - one for the global process config 92 + - one for isolated temporary readers 93 + 94 + That distinction is reasonable. 95 + 96 + What is not ideal is that the defaults themselves are duplicated instead of sharing a single authority. 97 + 98 + ## 6) Current Problem 99 + 100 + The current issue is not that there are two call paths. 101 + The current issue is that both paths each define defaults directly. 102 + 103 + That means whenever a new config key is added, especially under `tools.*`, there is a risk of: 104 + 105 + - updating one defaults function but not the other 106 + - drifting platform-specific behavior 107 + - drifting Console Settings behavior from main runtime behavior 108 + 109 + This is the structural reason the PowerShell work had to touch both files. 110 + 111 + ## 7) Recommended Direction 112 + 113 + The likely cleanup direction is: 114 + 115 + - keep both call paths 116 + - reduce to one authority for shared defaults 117 + 118 + Possible shape: 119 + 120 + - `internal/configdefaults.Apply` remains the shared source of truth 121 + - `integration.ApplyViperDefaults` calls into it first 122 + - `integration.ApplyViperDefaults` only adds integration-specific defaults if it truly owns any 123 + 124 + This keeps the useful construction split without duplicating the actual default values. 125 + 126 + ## 8) Recommendation For Scope 127 + 128 + This should be treated as a follow-up cleanup, not part of the PowerShell feature delivery itself. 129 + 130 + Reason: 131 + 132 + - it changes configuration architecture 133 + - it is broader than shell-tool support 134 + - it deserves a refactor-only change with regression checks around settings loading
+373
docs/feat/feat_20260418_shell_tool_simplification.md
··· 1 + --- 2 + date: 2026-04-18 3 + title: Shell Tool Simplification 4 + status: draft 5 + --- 6 + 7 + # Shell Tool Simplification 8 + 9 + ## 1) Goal 10 + 11 + This note turns the previous simplification idea into an executable design. 12 + 13 + The goal is not to redesign shell selection in this branch. 14 + The goal is to reduce duplicated shell execution logic while preserving the currently shipped behavior of: 15 + 16 + - `bash` 17 + - `powershell` 18 + 19 + ## 2) Scope 20 + 21 + This design is intentionally narrow. 22 + 23 + In scope: 24 + 25 + - extract one shared shell execution path 26 + - keep `bash` and `powershell` as separate tools 27 + - keep current config keys 28 + - keep current Console Settings model 29 + - keep current prompt behavior 30 + 31 + Out of scope: 32 + 33 + - replacing dual shell booleans with a single shell mode 34 + - making `powershell` feature-parity with `bash` 35 + - changing tool names or payload shape 36 + - redesigning registry/config/defaults architecture in the same refactor 37 + 38 + ## 3) Current Duplication 39 + 40 + Today the two tool implementations duplicate most of the same lifecycle: 41 + 42 + - enabled check 43 + - `cmd` parsing 44 + - alias expansion for `file_cache_dir` and `file_state_dir` 45 + - deny-path and deny-token checks 46 + - `cwd` resolution 47 + - timeout parsing 48 + - process launch 49 + - stdout/stderr collection 50 + - observation formatting 51 + 52 + The most important difference is not the shell itself. 53 + The most important difference is behavior above the runner: 54 + 55 + - `bash` supports `run_in_subtask` 56 + - `bash` emits streamed tool output events 57 + - `powershell` does not currently do either 58 + 59 + That means the simplification target should be: 60 + 61 + - unify the shared execution core 62 + - leave tool-specific behavior at the edge 63 + 64 + ## 4) Design Principles 65 + 66 + The refactor should obey these rules: 67 + 68 + 1. No behavior regressions in normal `bash` execution. 69 + 2. No behavior regressions in normal `powershell` execution. 70 + 3. `run_in_subtask` stays bash-only until explicitly expanded later. 71 + 4. Existing observation text format stays unchanged. 72 + 5. Existing config keys stay unchanged. 73 + 6. Streaming remains opt-in at the runner boundary rather than becoming implicit. 74 + 75 + ## 5) Target Architecture 76 + 77 + The desired structure is: 78 + 79 + ```text 80 + BashTool.Execute 81 + -> parse common shell request 82 + -> if run_in_subtask: existing bash-only path 83 + -> run shared shell runner with bash launcher 84 + 85 + PowerShellTool.Execute 86 + -> parse common shell request 87 + -> run shared shell runner with powershell launcher 88 + ``` 89 + 90 + The shared part should live in a new internal helper such as: 91 + 92 + - `tools/builtin/shell_runner.go` 93 + 94 + The tool files should become thin wrappers: 95 + 96 + - `bash.go` 97 + - `powershell.go` 98 + 99 + ## 6) Proposed Shared Types 100 + 101 + The exact names can change, but the shape should be close to this: 102 + 103 + ```go 104 + type shellToolCommon struct { 105 + ToolName string 106 + Enabled bool 107 + DefaultTimeout time.Duration 108 + MaxOutputBytes int 109 + BaseDirs []string 110 + DenyPaths []string 111 + DenyTokens []string 112 + InjectedEnvVars []string 113 + } 114 + 115 + type shellInvocation struct { 116 + Command string 117 + CWD string 118 + Timeout time.Duration 119 + } 120 + 121 + type shellRunnerSpec struct { 122 + Program string 123 + ArgsPrefix []string 124 + BuildEnv func(injected []string) []string 125 + MatchDeniedPath func(cmd string, denyPaths []string) (string, bool) 126 + StreamOutput bool 127 + EmitChunk func(ctx context.Context, stream, text string) 128 + } 129 + 130 + type shellExecutionResult struct { 131 + ExitCode int 132 + StdoutTruncated bool 133 + StderrTruncated bool 134 + Stdout string 135 + Stderr string 136 + } 137 + ``` 138 + 139 + The point of this split is: 140 + 141 + - `shellToolCommon` owns stable config-derived behavior 142 + - `shellRunnerSpec` owns shell-specific launch details 143 + - `shellInvocation` is the parsed request 144 + - `shellExecutionResult` is the shared output contract 145 + 146 + ## 7) Proposed Shared Functions 147 + 148 + The shared helper layer should own these functions: 149 + 150 + ```go 151 + func prepareShellInvocation(params map[string]any, common shellToolCommon) (shellInvocation, error) 152 + func resolveShellCWD(baseDirs []string, raw string) (string, error) 153 + func expandShellPathAliases(baseDirs []string, cmd string) (string, error) 154 + func runShellCommand(ctx context.Context, common shellToolCommon, spec shellRunnerSpec, inv shellInvocation) (shellExecutionResult, error) 155 + func formatShellObservation(result shellExecutionResult) string 156 + ``` 157 + 158 + And likely also: 159 + 160 + ```go 161 + func readCommandPipes(...) 162 + func normalizeExecutionError(toolName string, timeout time.Duration, exitCode int, err error) error 163 + ``` 164 + 165 + These functions should not know about: 166 + 167 + - subtask orchestration 168 + - prompt wording 169 + - tool registry wiring 170 + 171 + They should only know how to execute a shell command safely and return a stable result. 172 + 173 + ## 8) What Moves Into Shared Code 174 + 175 + Move now: 176 + 177 + - timeout parsing from `params["timeout_seconds"]` 178 + - alias expansion 179 + - cwd resolution 180 + - deny-token check 181 + - UTF-8 cleanup 182 + - limited stdout/stderr buffering 183 + - common observation rendering 184 + - process execution loop 185 + 186 + Move with strategy hooks: 187 + 188 + - deny-path matching 189 + - bash uses current token matching 190 + - powershell uses current Windows-normalized matching 191 + - env construction 192 + - powershell still layers on top of the bash allowlist 193 + - output streaming 194 + - enabled for bash 195 + - disabled for powershell 196 + 197 + Do not move yet: 198 + 199 + - `run_in_subtask` 200 + - bash subtask result envelope helpers 201 + - any future parity work 202 + 203 + ## 9) Bash Wrapper After Refactor 204 + 205 + After the refactor, `BashTool.Execute` should keep its current public behavior: 206 + 207 + 1. Reject if disabled. 208 + 2. Parse common invocation via shared helper. 209 + 3. If `run_in_subtask` is true, keep current bash-specific subtask path. 210 + 4. Otherwise call the shared runner with: 211 + - `Program: "bash"` 212 + - `ArgsPrefix: []string{"-lc"}` 213 + - `BuildEnv: bashToolEnv` 214 + - `MatchDeniedPath: bashCommandDenied` 215 + - `StreamOutput: true` 216 + - `EmitChunk: bash tool output emitter` 217 + 5. Format the result with the shared observation formatter. 218 + 219 + This preserves the bash-specific behavior while deleting duplicated low-level code. 220 + 221 + ## 10) PowerShell Wrapper After Refactor 222 + 223 + After the refactor, `PowerShellTool.Execute` should: 224 + 225 + 1. Reject if disabled. 226 + 2. Parse common invocation via shared helper. 227 + 3. Call the shared runner with: 228 + - `Program: "powershell"` 229 + - `ArgsPrefix: []string{"-NoProfile", "-Command"}` 230 + - `BuildEnv: powershellToolEnv` 231 + - `MatchDeniedPath: powershellCommandDenied` 232 + - `StreamOutput: false` 233 + 4. Format the result with the shared observation formatter. 234 + 235 + This keeps the current product decision intact: 236 + 237 + - PowerShell works 238 + - it stays simpler than bash for now 239 + 240 + ## 11) File Layout Proposal 241 + 242 + Suggested file split: 243 + 244 + - `tools/builtin/shell_runner.go` 245 + - shared types 246 + - shared runner 247 + - common observation formatting 248 + - `tools/builtin/shell_paths.go` 249 + - alias expansion 250 + - cwd resolution 251 + - `tools/builtin/shell_output.go` 252 + - limited buffer 253 + - shared pipe reading helpers 254 + 255 + Possible alternative: 256 + 257 + - keep it all in `shell_runner.go` first 258 + 259 + Recommendation: 260 + 261 + - start with one file 262 + - split later only if it becomes noisy 263 + 264 + That keeps the first refactor easier to review. 265 + 266 + ## 12) Migration Plan 267 + 268 + ### Step 1 269 + 270 + Introduce shared types and pure helper functions only: 271 + 272 + - invocation parsing 273 + - cwd resolution 274 + - alias expansion 275 + - observation formatting 276 + 277 + No behavior change yet. 278 + 279 + ### Step 2 280 + 281 + Introduce shared process runner with optional streaming callback. 282 + 283 + Use it first from `powershell`, because that path is simpler. 284 + 285 + ### Step 3 286 + 287 + Switch normal `bash` execution to the shared runner while keeping: 288 + 289 + - `run_in_subtask` 290 + - subtask envelope code 291 + - bash event emission 292 + 293 + ### Step 4 294 + 295 + Delete dead duplicated helpers from `bash.go` and `powershell.go`. 296 + 297 + ### Step 5 298 + 299 + Only after the runner refactor is stable, consider a separate follow-up for: 300 + 301 + - shell registration/config cleanup 302 + - shell selection model cleanup 303 + 304 + ## 13) Testing Plan 305 + 306 + The refactor should add or preserve tests around these behaviors: 307 + 308 + - bash env allowlist behavior 309 + - powershell env allowlist behavior 310 + - bash deny-path matching 311 + - powershell deny-path Windows normalization 312 + - cwd alias resolution 313 + - identical observation format for both tools 314 + - bash streaming path still emits output events 315 + - bash `run_in_subtask` behavior unchanged 316 + 317 + The most important regression tests are not shell-specific. 318 + They are contract-specific: 319 + 320 + - same error messages 321 + - same observation layout 322 + - same deny behavior 323 + 324 + ## 14) Risks 325 + 326 + ### Risk 1 327 + 328 + Shared code accidentally drifts toward bash semantics and changes PowerShell behavior. 329 + 330 + Mitigation: 331 + 332 + - keep deny-path matcher and env builder injected 333 + - do not normalize everything to one shell policy 334 + 335 + ### Risk 2 336 + 337 + Streaming support leaks into PowerShell unintentionally. 338 + 339 + Mitigation: 340 + 341 + - make streaming an explicit `StreamOutput` option 342 + - no default-on behavior in the runner 343 + 344 + ### Risk 3 345 + 346 + Refactor scope grows into config and UI cleanup. 347 + 348 + Mitigation: 349 + 350 + - keep config keys untouched 351 + - keep Console Settings untouched 352 + - keep prompt logic untouched 353 + 354 + ## 15) Follow-Up Opportunities 355 + 356 + After the runner extraction is complete, the next reasonable simplifications are: 357 + 358 + - collapse `StaticBashConfig` and `StaticPowerShellConfig` around a shared shell config sub-struct 359 + - reduce duplicate registration code in `internal/toolsutil/static_register.go` 360 + - later decide whether shell enablement should remain two booleans or become one mode 361 + 362 + These should be separate changes. 363 + 364 + ## 16) Recommendation 365 + 366 + The recommended next implementation is: 367 + 368 + 1. build a shared shell runner 369 + 2. migrate `powershell` first 370 + 3. migrate normal `bash` execution second 371 + 4. leave shell selection/config redesign for later 372 + 373 + That is the lowest-risk path that still meaningfully reduces overdesign.
+23 -2
docs/tools.md
··· 7 7 ### 1) Tool classes in current code 8 8 9 9 - `static` tools (fully constructable from config only): 10 - - `read_file`, `write_file`, `bash`, `url_fetch`, `web_search`, `contacts_send`. 10 + - `read_file`, `write_file`, `bash`, `powershell`, `url_fetch`, `web_search`, `contacts_send`. 11 11 - `engine-scoped` tools: 12 12 - `spawn`: registered when an agent engine is assembled for a run; depends on the current subtask runner, parent tool lookup, and default model. 13 13 - `acp_spawn`: registered when an agent engine is assembled for a run; depends on ACP agent profiles plus the current subtask runner. ··· 137 137 | `cmd` | `string` | Yes | None | Bash command to execute. Supports `file_cache_dir/...` and `file_state_dir/...` aliases. | 138 138 | `cwd` | `string` | No | Current directory | Working directory for command execution. Supports `file_cache_dir/...` and `file_state_dir/...` aliases. | 139 139 | `timeout_seconds` | `number` | No | `tools.bash.timeout` | Timeout override in seconds. | 140 + | `run_in_subtask` | `boolean` | No | `false` | If `true`, run the command inside the direct bash subtask boundary and return the structured subtask envelope JSON. | 140 141 141 142 Constraints: 142 143 143 - - Can be disabled via `tools.bash.enabled`. 144 + - Default enablement is platform-specific: enabled by default on Linux/macOS, disabled by default on Windows. Override with `tools.bash.enabled`. 144 145 - Restricted by `tools.bash.deny_paths` and internal deny-token rules. 145 146 - Runs with an allowlisted environment instead of inheriting the full parent process environment. 146 147 - Extra environment variables can be injected explicitly via `tools.bash.injected_env_vars`. 148 + 149 + ## `powershell` 150 + 151 + Purpose: execute local PowerShell commands. 152 + 153 + Parameters: 154 + 155 + | Parameter | Type | Required | Default | Description | 156 + |---|---|---|---|---| 157 + | `cmd` | `string` | Yes | None | PowerShell command to execute. Supports `file_cache_dir/...`, `file_state_dir/...`, and backslash variants such as `file_cache_dir\...`. | 158 + | `cwd` | `string` | No | Current directory | Working directory for command execution. Supports `file_cache_dir/...` and `file_state_dir/...` aliases. | 159 + | `timeout_seconds` | `number` | No | `tools.powershell.timeout` | Timeout override in seconds. | 160 + 161 + Constraints: 162 + 163 + - Default enablement is platform-specific: enabled by default on Windows, disabled by default on Linux/macOS. Override with `tools.powershell.enabled`. 164 + - Restricted by `tools.powershell.deny_paths` and internal deny-token rules. 165 + - Runs with an allowlisted environment instead of inheriting the full parent process environment. 166 + - Extra environment variables can be injected explicitly via `tools.powershell.injected_env_vars`. 167 + - Unlike `bash`, it does not currently support `run_in_subtask`. 147 168 148 169 ## `url_fetch` 149 170
+15
guard/guard.go
··· 197 197 } 198 198 } 199 199 return Result{RiskLevel: RiskLow, Decision: DecisionAllow} 200 + case "powershell": 201 + if g.cfg.Approvals.Enabled { 202 + return Result{ 203 + RiskLevel: RiskHigh, 204 + Decision: DecisionRequireApproval, 205 + Reasons: []string{"powershell_requires_approval"}, 206 + } 207 + } 208 + return Result{RiskLevel: RiskLow, Decision: DecisionAllow} 200 209 case "url_fetch": 201 210 rawURL := "" 202 211 if a.ToolParams != nil { ··· 390 399 return string(a.Type) + " tool=bash" 391 400 } 392 401 return fmt.Sprintf("%s tool=bash cmd=%q", string(a.Type), g.redactAuditValue(rawCmd, 320)) 402 + case "powershell": 403 + rawCmd := toolParamString(a.ToolParams, "cmd") 404 + if rawCmd == "" { 405 + return string(a.Type) + " tool=powershell" 406 + } 407 + return fmt.Sprintf("%s tool=powershell cmd=%q", string(a.Type), g.redactAuditValue(rawCmd, 320)) 393 408 } 394 409 return string(a.Type) + " tool=" + strings.TrimSpace(a.ToolName) 395 410 case ActionOutputPublish:
+12 -1
integration/defaults.go
··· 3 3 import ( 4 4 "time" 5 5 6 + "github.com/quailyquaily/mistermorph/internal/platformutil" 6 7 "github.com/spf13/viper" 7 8 ) 8 9 ··· 43 44 v.SetDefault("tools.write_file.max_bytes", 512*1024) 44 45 v.SetDefault("tools.spawn.enabled", true) 45 46 v.SetDefault("tools.acp_spawn.enabled", false) 46 - v.SetDefault("tools.bash.enabled", true) 47 + if platformutil.IsWindows() { 48 + v.SetDefault("tools.bash.enabled", false) 49 + v.SetDefault("tools.powershell.enabled", true) 50 + } else { 51 + v.SetDefault("tools.bash.enabled", true) 52 + v.SetDefault("tools.powershell.enabled", false) 53 + } 47 54 v.SetDefault("tools.bash.timeout", 30*time.Second) 48 55 v.SetDefault("tools.bash.max_output_bytes", 256*1024) 49 56 v.SetDefault("tools.bash.deny_paths", []string{"config.yaml"}) 50 57 v.SetDefault("tools.bash.injected_env_vars", []string{}) 58 + v.SetDefault("tools.powershell.timeout", 30*time.Second) 59 + v.SetDefault("tools.powershell.max_output_bytes", 256*1024) 60 + v.SetDefault("tools.powershell.deny_paths", []string{"config.yaml"}) 61 + v.SetDefault("tools.powershell.injected_env_vars", []string{}) 51 62 v.SetDefault("tools.url_fetch.enabled", true) 52 63 v.SetDefault("tools.url_fetch.timeout", 30*time.Second) 53 64 v.SetDefault("tools.url_fetch.max_bytes", int64(512*1024))
+7
integration/registry.go
··· 78 78 DenyPaths: append([]string(nil), cfg.ToolsBashDenyPaths...), 79 79 InjectedEnvVars: append([]string(nil), cfg.ToolsBashInjectedEnvVars...), 80 80 }, 81 + PowerShell: toolsutil.StaticPowerShellConfig{ 82 + Enabled: cfg.ToolsPowerShellEnabled, 83 + Timeout: cfg.ToolsPowerShellTimeout, 84 + MaxOutputBytes: cfg.ToolsPowerShellMaxOutputBytes, 85 + DenyPaths: append([]string(nil), cfg.ToolsPowerShellDenyPaths...), 86 + InjectedEnvVars: append([]string(nil), cfg.ToolsPowerShellInjectedEnvVars...), 87 + }, 81 88 URLFetch: toolsutil.StaticURLFetchConfig{ 82 89 Enabled: cfg.ToolsURLFetchEnabled, 83 90 Timeout: cfg.ToolsURLFetchTimeout,
+5
integration/runtime_snapshot.go
··· 47 47 ToolsBashMaxOutputBytes int 48 48 ToolsBashDenyPaths []string 49 49 ToolsBashInjectedEnvVars []string 50 + ToolsPowerShellEnabled bool 51 + ToolsPowerShellTimeout time.Duration 52 + ToolsPowerShellMaxOutputBytes int 53 + ToolsPowerShellDenyPaths []string 54 + ToolsPowerShellInjectedEnvVars []string 50 55 ToolsURLFetchEnabled bool 51 56 ToolsURLFetchTimeout time.Duration 52 57 ToolsURLFetchMaxBytes int64
+5
integration/runtime_snapshot_loader.go
··· 87 87 ToolsBashMaxOutputBytes: v.GetInt("tools.bash.max_output_bytes"), 88 88 ToolsBashDenyPaths: append([]string(nil), v.GetStringSlice("tools.bash.deny_paths")...), 89 89 ToolsBashInjectedEnvVars: append([]string(nil), v.GetStringSlice("tools.bash.injected_env_vars")...), 90 + ToolsPowerShellEnabled: v.GetBool("tools.powershell.enabled"), 91 + ToolsPowerShellTimeout: v.GetDuration("tools.powershell.timeout"), 92 + ToolsPowerShellMaxOutputBytes: v.GetInt("tools.powershell.max_output_bytes"), 93 + ToolsPowerShellDenyPaths: append([]string(nil), v.GetStringSlice("tools.powershell.deny_paths")...), 94 + ToolsPowerShellInjectedEnvVars: append([]string(nil), v.GetStringSlice("tools.powershell.injected_env_vars")...), 90 95 ToolsURLFetchEnabled: v.GetBool("tools.url_fetch.enabled"), 91 96 ToolsURLFetchTimeout: v.GetDuration("tools.url_fetch.timeout"), 92 97 ToolsURLFetchMaxBytes: v.GetInt64("tools.url_fetch.max_bytes"),
+226
internal/configbootstrap/bootstrap.go
··· 1 + package configbootstrap 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/quailyquaily/mistermorph/integration" 7 + "github.com/spf13/viper" 8 + "gopkg.in/yaml.v3" 9 + ) 10 + 11 + type LLMConfig struct { 12 + Provider string 13 + Endpoint string 14 + Model string 15 + APIKey string 16 + CloudflareAccountID string 17 + CloudflareAPIToken string 18 + } 19 + 20 + type ConsoleEndpoint struct { 21 + Name string 22 + URL string 23 + AuthToken string 24 + } 25 + 26 + type ConsoleConfig struct { 27 + Listen string 28 + BasePath string 29 + Password string 30 + ManagedKinds []string 31 + Endpoints []ConsoleEndpoint 32 + } 33 + 34 + type Config struct { 35 + FileStateDir string 36 + ServerAuthToken string 37 + LLM LLMConfig 38 + Console *ConsoleConfig 39 + } 40 + 41 + func Apply(base []byte, cfg Config) ([]byte, error) { 42 + doc, err := loadDocument(base) 43 + if err != nil { 44 + return nil, err 45 + } 46 + root, err := DocumentMapping(doc) 47 + if err != nil { 48 + return nil, err 49 + } 50 + if dir := strings.TrimSpace(cfg.FileStateDir); dir != "" { 51 + SetOrDeleteMappingScalar(root, "file_state_dir", dir) 52 + } 53 + 54 + values := defaultRuntimeValues() 55 + applyAgentDefaults(root, values, cfg.LLM) 56 + 57 + if consoleCfg := cfg.Console; consoleCfg != nil { 58 + applyConsoleConfig(root, *consoleCfg) 59 + } 60 + if token := strings.TrimSpace(cfg.ServerAuthToken); token != "" { 61 + serverNode := EnsureMappingValue(root, "server") 62 + SetOrDeleteMappingScalar(serverNode, "auth_token", token) 63 + } 64 + 65 + return MarshalDocument(doc) 66 + } 67 + 68 + type runtimeValues struct { 69 + Provider string 70 + MultimodalSources []string 71 + ToolsWriteFile bool 72 + ToolsSpawn bool 73 + ToolsContactsSend bool 74 + ToolsTodoUpdate bool 75 + ToolsPlanCreate bool 76 + ToolsURLFetch bool 77 + ToolsWebSearch bool 78 + ToolsBash bool 79 + ToolsPowerShell bool 80 + } 81 + 82 + func loadDocument(base []byte) (*yaml.Node, error) { 83 + return LoadDocumentBytes(base) 84 + } 85 + 86 + func defaultRuntimeValues() runtimeValues { 87 + tmp := viper.New() 88 + integration.ApplyViperDefaults(tmp) 89 + return runtimeValues{ 90 + Provider: strings.TrimSpace(tmp.GetString("llm.provider")), 91 + MultimodalSources: append([]string(nil), tmp.GetStringSlice("multimodal.image.sources")...), 92 + ToolsWriteFile: tmp.GetBool("tools.write_file.enabled"), 93 + ToolsSpawn: tmp.GetBool("tools.spawn.enabled"), 94 + ToolsContactsSend: tmp.GetBool("tools.contacts_send.enabled"), 95 + ToolsTodoUpdate: tmp.GetBool("tools.todo_update.enabled"), 96 + ToolsPlanCreate: tmp.GetBool("tools.plan_create.enabled"), 97 + ToolsURLFetch: tmp.GetBool("tools.url_fetch.enabled"), 98 + ToolsWebSearch: tmp.GetBool("tools.web_search.enabled"), 99 + ToolsBash: tmp.GetBool("tools.bash.enabled"), 100 + ToolsPowerShell: tmp.GetBool("tools.powershell.enabled"), 101 + } 102 + } 103 + 104 + func applyAgentDefaults(root *yaml.Node, values runtimeValues, cfg LLMConfig) { 105 + llmNode := EnsureMappingValue(root, "llm") 106 + provider := strings.TrimSpace(cfg.Provider) 107 + if provider == "" { 108 + provider = strings.TrimSpace(values.Provider) 109 + } 110 + SetOrDeleteMappingScalar(llmNode, "provider", provider) 111 + SetOrDeleteMappingScalar(llmNode, "endpoint", strings.TrimSpace(cfg.Endpoint)) 112 + SetOrDeleteMappingScalar(llmNode, "model", strings.TrimSpace(cfg.Model)) 113 + 114 + if strings.EqualFold(provider, "cloudflare") { 115 + SetOrDeleteMappingScalar(llmNode, "api_key", "") 116 + cloudflareNode := EnsureMappingValue(llmNode, "cloudflare") 117 + SetOrDeleteMappingScalar(cloudflareNode, "account_id", strings.TrimSpace(cfg.CloudflareAccountID)) 118 + SetOrDeleteMappingScalar(cloudflareNode, "api_token", strings.TrimSpace(cfg.CloudflareAPIToken)) 119 + if len(cloudflareNode.Content) == 0 { 120 + DeleteMappingKey(llmNode, "cloudflare") 121 + } 122 + } else { 123 + SetOrDeleteMappingScalar(llmNode, "api_key", strings.TrimSpace(cfg.APIKey)) 124 + DeleteMappingKey(llmNode, "cloudflare") 125 + } 126 + 127 + multimodalNode := EnsureMappingValue(root, "multimodal") 128 + imageNode := EnsureMappingValue(multimodalNode, "image") 129 + SetMappingStringList(imageNode, "sources", normalizeLowercaseList(values.MultimodalSources)) 130 + 131 + toolsNode := EnsureMappingValue(root, "tools") 132 + SetMappingBoolPath(toolsNode, "write_file", "enabled", values.ToolsWriteFile) 133 + SetMappingBoolPath(toolsNode, "spawn", "enabled", values.ToolsSpawn) 134 + SetMappingBoolPath(toolsNode, "contacts_send", "enabled", values.ToolsContactsSend) 135 + SetMappingBoolPath(toolsNode, "todo_update", "enabled", values.ToolsTodoUpdate) 136 + SetMappingBoolPath(toolsNode, "plan_create", "enabled", values.ToolsPlanCreate) 137 + SetMappingBoolPath(toolsNode, "url_fetch", "enabled", values.ToolsURLFetch) 138 + SetMappingBoolPath(toolsNode, "web_search", "enabled", values.ToolsWebSearch) 139 + SetMappingBoolPath(toolsNode, "bash", "enabled", values.ToolsBash) 140 + SetMappingBoolPath(toolsNode, "powershell", "enabled", values.ToolsPowerShell) 141 + } 142 + 143 + func applyConsoleConfig(root *yaml.Node, cfg ConsoleConfig) { 144 + consoleNode := EnsureMappingValue(root, "console") 145 + if listen := strings.TrimSpace(cfg.Listen); listen != "" { 146 + SetOrDeleteMappingScalar(consoleNode, "listen", listen) 147 + } 148 + if basePath := strings.TrimSpace(cfg.BasePath); basePath != "" { 149 + SetOrDeleteMappingScalar(consoleNode, "base_path", basePath) 150 + } 151 + if password := strings.TrimSpace(cfg.Password); password != "" { 152 + SetOrDeleteMappingScalar(consoleNode, "password", password) 153 + } 154 + if cfg.ManagedKinds != nil { 155 + SetMappingStringList(consoleNode, "managed_runtimes", normalizeTrimmedList(cfg.ManagedKinds)) 156 + } 157 + setConsoleEndpoints(consoleNode, cfg.Endpoints) 158 + } 159 + 160 + func setConsoleEndpoints(consoleNode *yaml.Node, endpoints []ConsoleEndpoint) { 161 + if consoleNode == nil || consoleNode.Kind != yaml.MappingNode { 162 + return 163 + } 164 + list := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} 165 + for _, endpoint := range endpoints { 166 + name := strings.TrimSpace(endpoint.Name) 167 + url := strings.TrimSpace(endpoint.URL) 168 + authToken := strings.TrimSpace(endpoint.AuthToken) 169 + if name == "" || url == "" || authToken == "" { 170 + continue 171 + } 172 + item := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} 173 + SetOrDeleteMappingScalar(item, "name", name) 174 + SetOrDeleteMappingScalar(item, "url", url) 175 + SetOrDeleteMappingScalar(item, "auth_token", authToken) 176 + list.Content = append(list.Content, item) 177 + } 178 + if existing := FindMappingValue(consoleNode, "endpoints"); existing != nil { 179 + *existing = *list 180 + return 181 + } 182 + consoleNode.Content = append(consoleNode.Content, 183 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "endpoints"}, 184 + list, 185 + ) 186 + } 187 + 188 + func normalizeTrimmedList(values []string) []string { 189 + if len(values) == 0 { 190 + return nil 191 + } 192 + out := make([]string, 0, len(values)) 193 + seen := make(map[string]struct{}, len(values)) 194 + for _, raw := range values { 195 + value := strings.TrimSpace(raw) 196 + if value == "" { 197 + continue 198 + } 199 + if _, ok := seen[value]; ok { 200 + continue 201 + } 202 + seen[value] = struct{}{} 203 + out = append(out, value) 204 + } 205 + return out 206 + } 207 + 208 + func normalizeLowercaseList(values []string) []string { 209 + if len(values) == 0 { 210 + return nil 211 + } 212 + out := make([]string, 0, len(values)) 213 + seen := make(map[string]struct{}, len(values)) 214 + for _, raw := range values { 215 + value := strings.TrimSpace(strings.ToLower(raw)) 216 + if value == "" { 217 + continue 218 + } 219 + if _, ok := seen[value]; ok { 220 + continue 221 + } 222 + seen[value] = struct{}{} 223 + out = append(out, value) 224 + } 225 + return out 226 + }
+192
internal/configbootstrap/document.go
··· 1 + package configbootstrap 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "strings" 7 + 8 + "gopkg.in/yaml.v3" 9 + ) 10 + 11 + func LoadDocumentBytes(data []byte) (*yaml.Node, error) { 12 + if len(bytes.TrimSpace(data)) == 0 { 13 + return NewEmptyDocument(), nil 14 + } 15 + var doc yaml.Node 16 + if err := yaml.Unmarshal(data, &doc); err != nil { 17 + return nil, fmt.Errorf("invalid config yaml: %w", err) 18 + } 19 + if len(doc.Content) == 0 { 20 + doc.Content = []*yaml.Node{{ 21 + Kind: yaml.MappingNode, 22 + Tag: "!!map", 23 + }} 24 + } 25 + return &doc, nil 26 + } 27 + 28 + func NewEmptyDocument() *yaml.Node { 29 + return &yaml.Node{ 30 + Kind: yaml.DocumentNode, 31 + Content: []*yaml.Node{{ 32 + Kind: yaml.MappingNode, 33 + Tag: "!!map", 34 + }}, 35 + } 36 + } 37 + 38 + func DocumentMapping(doc *yaml.Node) (*yaml.Node, error) { 39 + if doc == nil { 40 + return nil, fmt.Errorf("config document is nil") 41 + } 42 + if doc.Kind == yaml.DocumentNode { 43 + if len(doc.Content) == 0 { 44 + doc.Content = []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}} 45 + } 46 + doc = doc.Content[0] 47 + } 48 + if doc.Kind != yaml.MappingNode { 49 + return nil, fmt.Errorf("config root must be a yaml mapping") 50 + } 51 + return doc, nil 52 + } 53 + 54 + func MarshalDocument(doc *yaml.Node) ([]byte, error) { 55 + var buf bytes.Buffer 56 + enc := yaml.NewEncoder(&buf) 57 + enc.SetIndent(2) 58 + if err := enc.Encode(doc); err != nil { 59 + _ = enc.Close() 60 + return nil, err 61 + } 62 + if err := enc.Close(); err != nil { 63 + return nil, err 64 + } 65 + return buf.Bytes(), nil 66 + } 67 + 68 + func FindMappingValue(node *yaml.Node, key string) *yaml.Node { 69 + if node == nil || node.Kind != yaml.MappingNode { 70 + return nil 71 + } 72 + for i := 0; i+1 < len(node.Content); i += 2 { 73 + if strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 74 + return node.Content[i+1] 75 + } 76 + } 77 + return nil 78 + } 79 + 80 + func EnsureMappingValue(node *yaml.Node, key string) *yaml.Node { 81 + if node == nil || node.Kind != yaml.MappingNode { 82 + return nil 83 + } 84 + if value := FindMappingValue(node, key); value != nil { 85 + if value.Kind == yaml.MappingNode { 86 + return value 87 + } 88 + *value = yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} 89 + return value 90 + } 91 + child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} 92 + node.Content = append(node.Content, 93 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 94 + child, 95 + ) 96 + return child 97 + } 98 + 99 + func SetOrDeleteMappingScalar(node *yaml.Node, key, value string) { 100 + if node == nil || node.Kind != yaml.MappingNode { 101 + return 102 + } 103 + value = strings.TrimSpace(value) 104 + for i := 0; i+1 < len(node.Content); i += 2 { 105 + if !strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 106 + continue 107 + } 108 + if value == "" { 109 + node.Content = append(node.Content[:i], node.Content[i+2:]...) 110 + return 111 + } 112 + node.Content[i+1] = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} 113 + return 114 + } 115 + if value == "" { 116 + return 117 + } 118 + node.Content = append(node.Content, 119 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 120 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, 121 + ) 122 + } 123 + 124 + func DeleteMappingKey(node *yaml.Node, key string) { 125 + if node == nil || node.Kind != yaml.MappingNode { 126 + return 127 + } 128 + for i := 0; i+1 < len(node.Content); i += 2 { 129 + if !strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 130 + continue 131 + } 132 + node.Content = append(node.Content[:i], node.Content[i+2:]...) 133 + return 134 + } 135 + } 136 + 137 + func SetMappingBoolValue(node *yaml.Node, key string, value bool) { 138 + if node == nil || node.Kind != yaml.MappingNode { 139 + return 140 + } 141 + for i := 0; i+1 < len(node.Content); i += 2 { 142 + if !strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 143 + continue 144 + } 145 + node.Content[i+1] = &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: boolString(value)} 146 + return 147 + } 148 + node.Content = append(node.Content, 149 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 150 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: boolString(value)}, 151 + ) 152 + } 153 + 154 + func SetMappingBoolPath(node *yaml.Node, section, key string, value bool) { 155 + sectionNode := EnsureMappingValue(node, section) 156 + if sectionNode == nil { 157 + return 158 + } 159 + SetMappingBoolValue(sectionNode, key, value) 160 + } 161 + 162 + func SetMappingStringList(node *yaml.Node, key string, values []string) { 163 + if node == nil || node.Kind != yaml.MappingNode { 164 + return 165 + } 166 + list := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} 167 + for _, raw := range values { 168 + value := strings.TrimSpace(raw) 169 + if value == "" { 170 + continue 171 + } 172 + list.Content = append(list.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}) 173 + } 174 + for i := 0; i+1 < len(node.Content); i += 2 { 175 + if !strings.EqualFold(strings.TrimSpace(node.Content[i].Value), key) { 176 + continue 177 + } 178 + node.Content[i+1] = list 179 + return 180 + } 181 + node.Content = append(node.Content, 182 + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, 183 + list, 184 + ) 185 + } 186 + 187 + func boolString(value bool) string { 188 + if value { 189 + return "true" 190 + } 191 + return "false" 192 + }
+17 -2
internal/configdefaults/defaults.go
··· 3 3 import ( 4 4 "time" 5 5 6 + "github.com/quailyquaily/mistermorph/internal/platformutil" 6 7 "github.com/spf13/viper" 7 8 ) 8 9 ··· 145 146 146 147 v.SetDefault("tools.write_file.enabled", true) 147 148 v.SetDefault("tools.write_file.max_bytes", 512*1024) 148 - 149 149 v.SetDefault("tools.spawn.enabled", true) 150 150 v.SetDefault("tools.acp_spawn.enabled", false) 151 - v.SetDefault("tools.bash.enabled", true) 151 + 152 + // Platform-specific shell tool defaults: 153 + // - Windows: PowerShell enabled, Bash disabled 154 + // - Unix-like (Linux/macOS): Bash enabled, PowerShell disabled 155 + if platformutil.IsWindows() { 156 + v.SetDefault("tools.bash.enabled", false) 157 + v.SetDefault("tools.powershell.enabled", true) 158 + } else { 159 + v.SetDefault("tools.bash.enabled", true) 160 + v.SetDefault("tools.powershell.enabled", false) 161 + } 152 162 v.SetDefault("tools.bash.timeout", 30*time.Second) 153 163 v.SetDefault("tools.bash.max_output_bytes", 256*1024) 154 164 v.SetDefault("tools.bash.deny_paths", []string{"config.yaml"}) 155 165 v.SetDefault("tools.bash.injected_env_vars", []string{}) 166 + 167 + v.SetDefault("tools.powershell.timeout", 30*time.Second) 168 + v.SetDefault("tools.powershell.max_output_bytes", 256*1024) 169 + v.SetDefault("tools.powershell.deny_paths", []string{"config.yaml"}) 170 + v.SetDefault("tools.powershell.injected_env_vars", []string{}) 156 171 157 172 v.SetDefault("tools.url_fetch.enabled", true) 158 173 v.SetDefault("tools.url_fetch.timeout", 30*time.Second)
+53
internal/platformutil/platform.go
··· 1 + // Package platformutil provides platform detection utilities. 2 + package platformutil 3 + 4 + import "runtime" 5 + 6 + // Platform constants 7 + const ( 8 + Windows = "windows" 9 + Linux = "linux" 10 + Darwin = "darwin" 11 + ) 12 + 13 + // IsWindows returns true if the current platform is Windows. 14 + func IsWindows() bool { 15 + return runtime.GOOS == Windows 16 + } 17 + 18 + // IsLinux returns true if the current platform is Linux. 19 + func IsLinux() bool { 20 + return runtime.GOOS == Linux 21 + } 22 + 23 + // IsDarwin returns true if the current platform is macOS. 24 + func IsDarwin() bool { 25 + return runtime.GOOS == Darwin 26 + } 27 + 28 + // IsUnixLike returns true if the current platform is Unix-like (Linux or macOS). 29 + func IsUnixLike() bool { 30 + return IsLinux() || IsDarwin() 31 + } 32 + 33 + // Current returns the current platform name. 34 + func Current() string { 35 + return runtime.GOOS 36 + } 37 + 38 + // ShellToolName returns the appropriate shell tool name for the current platform. 39 + // Returns "powershell" for Windows, "bash" for Unix-like systems. 40 + func ShellToolName() string { 41 + if IsWindows() { 42 + return "powershell" 43 + } 44 + return "bash" 45 + } 46 + 47 + // ShellToolDescription returns a description of the available shell tool for the current platform. 48 + func ShellToolDescription() string { 49 + if IsWindows() { 50 + return "PowerShell is available for command execution on Windows." 51 + } 52 + return "Bash is available for command execution on Unix-like systems." 53 + }
+27
internal/toolsutil/static_register.go
··· 12 12 BuiltinReadFile = "read_file" 13 13 BuiltinWriteFile = "write_file" 14 14 BuiltinBash = "bash" 15 + BuiltinPowerShell = "powershell" 15 16 BuiltinURLFetch = "url_fetch" 16 17 BuiltinWebSearch = "web_search" 17 18 BuiltinPlanCreate = "plan_create" ··· 25 26 BuiltinReadFile: {}, 26 27 BuiltinWriteFile: {}, 27 28 BuiltinBash: {}, 29 + BuiltinPowerShell: {}, 28 30 BuiltinURLFetch: {}, 29 31 BuiltinWebSearch: {}, 30 32 BuiltinPlanCreate: {}, ··· 39 41 ReadFile StaticReadFileConfig 40 42 WriteFile StaticWriteFileConfig 41 43 Bash StaticBashConfig 44 + PowerShell StaticPowerShellConfig 42 45 URLFetch StaticURLFetchConfig 43 46 WebSearch StaticWebSearchConfig 44 47 ContactsSend StaticContactsSendConfig ··· 62 65 } 63 66 64 67 type StaticBashConfig struct { 68 + Enabled bool 69 + Timeout time.Duration 70 + MaxOutputBytes int 71 + DenyPaths []string 72 + InjectedEnvVars []string 73 + } 74 + 75 + type StaticPowerShellConfig struct { 65 76 Enabled bool 66 77 Timeout time.Duration 67 78 MaxOutputBytes int ··· 149 160 bt.DenyTokens = append(bt.DenyTokens, "curl") 150 161 } 151 162 reg.Register(bt) 163 + } 164 + 165 + if isSelected(BuiltinPowerShell) && cfg.PowerShell.Enabled { 166 + pt := builtin.NewPowerShellTool( 167 + true, 168 + cfg.PowerShell.Timeout, 169 + cfg.PowerShell.MaxOutputBytes, 170 + strings.TrimSpace(cfg.Common.FileCacheDir), 171 + strings.TrimSpace(cfg.Common.FileStateDir), 172 + ) 173 + pt.DenyPaths = append([]string(nil), cfg.PowerShell.DenyPaths...) 174 + pt.InjectedEnvVars = append([]string(nil), cfg.PowerShell.InjectedEnvVars...) 175 + if cfg.Common.AuthenticatedHTTPConfigured { 176 + pt.DenyTokens = append(pt.DenyTokens, "curl") 177 + } 178 + reg.Register(pt) 152 179 } 153 180 154 181 if isSelected(BuiltinURLFetch) && cfg.URLFetch.Enabled {
+74 -203
tools/builtin/bash.go
··· 9 9 "io" 10 10 "os" 11 11 "os/exec" 12 - "path/filepath" 13 12 "strconv" 14 13 "strings" 15 - "sync" 16 14 "time" 17 15 18 16 "github.com/quailyquaily/mistermorph/agent" 19 - "github.com/quailyquaily/mistermorph/internal/pathutil" 20 17 "github.com/quailyquaily/mistermorph/tools" 21 18 ) 22 19 ··· 30 27 InjectedEnvVars []string 31 28 } 32 29 33 - type bashExecutionPayload struct { 34 - ExitCode int `json:"exit_code"` 35 - StdoutTruncated bool `json:"stdout_truncated"` 36 - StderrTruncated bool `json:"stderr_truncated"` 37 - Stdout string `json:"stdout"` 38 - Stderr string `json:"stderr"` 39 - } 30 + type bashExecutionPayload = shellExecutionPayload 40 31 41 32 func NewBashTool(enabled bool, defaultTimeout time.Duration, maxOutputBytes int, baseDirs ...string) *BashTool { 42 33 if defaultTimeout <= 0 { ··· 92 83 return "", fmt.Errorf("bash tool is disabled (enable via config: tools.bash.enabled=true)") 93 84 } 94 85 95 - cmdStr, _ := params["cmd"].(string) 96 - cmdStr = strings.TrimSpace(cmdStr) 97 - if cmdStr == "" { 98 - return "", fmt.Errorf("missing required param: cmd") 99 - } 100 - var err error 101 - cmdStr, err = t.expandPathAliasesInCommand(cmdStr) 102 - if err != nil { 103 - return "", err 104 - } 105 - 106 - if offending, ok := bashCommandDenied(cmdStr, t.DenyPaths); ok { 107 - return "", fmt.Errorf("bash command references denied path %q (configure via tools.bash.deny_paths)", offending) 108 - } 109 - if offending, ok := bashCommandDeniedTokens(cmdStr, t.DenyTokens); ok { 110 - return "", fmt.Errorf("bash command references denied token %q", offending) 111 - } 112 - 113 - cwd, _ := params["cwd"].(string) 114 - cwd = strings.TrimSpace(cwd) 115 - cwd, err = t.resolveCWD(cwd) 86 + inv, err := prepareShellInvocation(params, t.commonConfig(), t.runnerSpec()) 116 87 if err != nil { 117 88 return "", err 118 89 } 119 - 120 - timeout := t.DefaultTimeout 121 - if v, ok := params["timeout_seconds"]; ok { 122 - if secs, ok := asFloat64(v); ok && secs > 0 { 123 - timeout = time.Duration(secs * float64(time.Second)) 124 - } 125 - } 126 90 runInSubtask, _ := asBool(params["run_in_subtask"]) 127 91 if runInSubtask && agent.SubtaskDepthFromContext(ctx) == 0 { 128 - return t.executeInSubtask(ctx, cmdStr, cwd, timeout) 92 + return t.executeInSubtask(ctx, inv.Command, inv.CWD, inv.Timeout) 129 93 } 130 94 131 - payload, err := t.runCommand(ctx, cmdStr, cwd, timeout) 95 + payload, err := t.runCommand(ctx, inv.Command, inv.CWD, inv.Timeout) 132 96 observation := formatBashObservation(payload) 133 97 if err != nil { 134 98 return observation, err ··· 136 100 return observation, nil 137 101 } 138 102 103 + func (t *BashTool) commonConfig() shellToolCommon { 104 + return shellToolCommon{ 105 + ToolName: t.Name(), 106 + DefaultTimeout: t.DefaultTimeout, 107 + MaxOutputBytes: t.MaxOutputBytes, 108 + BaseDirs: append([]string(nil), t.BaseDirs...), 109 + DenyPaths: append([]string(nil), t.DenyPaths...), 110 + DenyTokens: append([]string(nil), t.DenyTokens...), 111 + InjectedEnvVars: append([]string(nil), t.InjectedEnvVars...), 112 + } 113 + } 114 + 115 + func (t *BashTool) runnerSpec() shellRunnerSpec { 116 + return shellRunnerSpec{ 117 + Program: "bash", 118 + ArgsPrefix: []string{"-lc"}, 119 + BuildEnv: bashToolEnv, 120 + TokenBoundary: isBashBoundaryByte, 121 + MatchDeniedPath: bashCommandDenied, 122 + StreamOutput: true, 123 + EmitChunk: t.emitChunk, 124 + TimeoutExitCode: 124, 125 + ReturnObservationOnExitError: true, 126 + ReturnObservationOnTimeout: true, 127 + ReturnObservationOnExecError: true, 128 + } 129 + } 130 + 131 + func (t *BashTool) emitChunk(ctx context.Context, stream, text string) { 132 + agent.EmitEvent(ctx, nil, agent.Event{ 133 + Kind: agent.EventKindToolOutput, 134 + ToolName: t.Name(), 135 + Profile: string(agent.ObserveProfileLongShell), 136 + Stream: stream, 137 + Text: text, 138 + Status: "running", 139 + }) 140 + } 141 + 139 142 func (t *BashTool) executeInSubtask(ctx context.Context, cmdStr string, cwd string, timeout time.Duration) (string, error) { 140 143 runner, ok := agent.SubtaskRunnerFromContext(ctx) 141 144 if !ok { ··· 160 163 } 161 164 162 165 func (t *BashTool) runCommand(ctx context.Context, cmdStr string, cwd string, timeout time.Duration) (bashExecutionPayload, error) { 163 - runCtx, cancel := context.WithTimeout(ctx, timeout) 164 - defer cancel() 165 - 166 - cmd := exec.CommandContext(runCtx, "bash", "-lc", cmdStr) 167 - if cwd != "" { 168 - cmd.Dir = cwd 169 - } 170 - cmd.Env = bashToolEnv(t.InjectedEnvVars) 171 - 172 - var stdout limitedBuffer 173 - var stderr limitedBuffer 174 - stdout.Limit = t.MaxOutputBytes 175 - stderr.Limit = t.MaxOutputBytes 176 - stdoutPipe, err := cmd.StdoutPipe() 177 - if err != nil { 178 - return bashExecutionPayload{}, err 179 - } 180 - stderrPipe, err := cmd.StderrPipe() 181 - if err != nil { 182 - return bashExecutionPayload{}, err 183 - } 184 - if err := cmd.Start(); err != nil { 185 - return bashExecutionPayload{}, err 186 - } 187 - 188 - var streamWG sync.WaitGroup 189 - var stdoutReadErr error 190 - var stderrReadErr error 191 - streamWG.Add(2) 192 - go func() { 193 - defer streamWG.Done() 194 - stdoutReadErr = t.captureCommandStream(runCtx, "stdout", stdoutPipe, &stdout) 195 - }() 196 - go func() { 197 - defer streamWG.Done() 198 - stderrReadErr = t.captureCommandStream(runCtx, "stderr", stderrPipe, &stderr) 199 - }() 200 - 201 - err = cmd.Wait() 202 - streamWG.Wait() 203 - if err == nil { 204 - if stdoutReadErr != nil { 205 - err = stdoutReadErr 206 - } else if stderrReadErr != nil { 207 - err = stderrReadErr 208 - } 209 - } 210 - exitCode := 0 211 - if err != nil { 212 - switch { 213 - case isExitError(err): 214 - exitCode = exitCodeFromError(err) 215 - case runCtx.Err() != nil: 216 - exitCode = 124 217 - err = fmt.Errorf("bash timed out after %s", timeout) 218 - default: 219 - exitCode = -1 220 - } 221 - } 222 - 223 - payload := bashExecutionPayload{ 224 - ExitCode: exitCode, 225 - StdoutTruncated: stdout.Truncated, 226 - StderrTruncated: stderr.Truncated, 227 - Stdout: string(bytes.ToValidUTF8(stdout.Bytes(), []byte("\n[non-utf8 output]\n"))), 228 - Stderr: string(bytes.ToValidUTF8(stderr.Bytes(), []byte("\n[non-utf8 output]\n"))), 229 - } 230 - 231 - if err == nil { 232 - return payload, nil 233 - } 234 - if exitCode > 0 && !strings.Contains(err.Error(), "timed out after") { 235 - err = fmt.Errorf("bash exited with code %d", exitCode) 236 - } 166 + payload, _, err := runShellCommand(ctx, t.commonConfig(), t.runnerSpec(), shellInvocation{ 167 + Command: cmdStr, 168 + CWD: cwd, 169 + Timeout: timeout, 170 + }) 237 171 return payload, err 238 172 } 239 173 240 174 func (t *BashTool) captureCommandStream(ctx context.Context, stream string, r io.Reader, dst *limitedBuffer) error { 241 - if r == nil || dst == nil { 242 - return nil 243 - } 244 - buf := make([]byte, 4096) 245 - for { 246 - n, err := r.Read(buf) 247 - if n > 0 { 248 - chunk := append([]byte(nil), buf[:n]...) 249 - _, _ = dst.Write(chunk) 250 - text := string(bytes.ToValidUTF8(chunk, []byte("\n[non-utf8 output]\n"))) 251 - if strings.TrimSpace(text) != "" { 252 - agent.EmitEvent(ctx, nil, agent.Event{ 253 - Kind: agent.EventKindToolOutput, 254 - ToolName: t.Name(), 255 - Profile: string(agent.ObserveProfileLongShell), 256 - Stream: stream, 257 - Text: text, 258 - Status: "running", 259 - }) 260 - } 261 - } 262 - if err == nil { 263 - continue 264 - } 265 - if err == io.EOF { 266 - return nil 267 - } 268 - if isBenignCommandStreamReadError(err) { 269 - return nil 270 - } 271 - select { 272 - case <-ctx.Done(): 273 - return nil 274 - default: 275 - return err 276 - } 277 - } 175 + return readShellPipe(ctx, stream, r, dst, func(stream, text string) { 176 + t.emitChunk(ctx, stream, text) 177 + }) 278 178 } 279 179 280 180 func formatBashObservation(payload bashExecutionPayload) string { 281 - var out strings.Builder 282 - fmt.Fprintf(&out, "exit_code: %d\n", payload.ExitCode) 283 - fmt.Fprintf(&out, "stdout_truncated: %t\n", payload.StdoutTruncated) 284 - fmt.Fprintf(&out, "stderr_truncated: %t\n", payload.StderrTruncated) 285 - out.WriteString("stdout:\n") 286 - out.WriteString(payload.Stdout) 287 - out.WriteString("\n\nstderr:\n") 288 - out.WriteString(payload.Stderr) 289 - return out.String() 181 + return formatShellObservation(payload) 290 182 } 291 183 292 184 func isBenignCommandStreamReadError(err error) bool { ··· 423 315 return key 424 316 } 425 317 426 - func (t *BashTool) resolveCWD(raw string) (string, error) { 427 - raw = strings.TrimSpace(raw) 428 - if raw == "" { 429 - return "", nil 430 - } 431 - alias, rest := detectWritePathAlias(raw) 432 - if alias == "" { 433 - return pathutil.ExpandHomePath(raw), nil 434 - } 435 - base := selectBaseForAlias(t.BaseDirs, alias) 436 - if strings.TrimSpace(base) == "" { 437 - return "", fmt.Errorf("base dir %s is not configured", alias) 438 - } 439 - rest = strings.TrimLeft(strings.TrimSpace(rest), "/\\") 440 - if rest == "" { 441 - return filepath.Clean(base), nil 442 - } 443 - return filepath.Clean(filepath.Join(base, rest)), nil 444 - } 445 - 446 - func (t *BashTool) expandPathAliasesInCommand(cmd string) (string, error) { 447 - var err error 448 - cmd, err = replaceAliasTokenInCommand(cmd, "file_cache_dir", selectBaseForAlias(t.BaseDirs, "file_cache_dir")) 449 - if err != nil { 450 - return "", err 451 - } 452 - cmd, err = replaceAliasTokenInCommand(cmd, "file_state_dir", selectBaseForAlias(t.BaseDirs, "file_state_dir")) 453 - if err != nil { 454 - return "", err 455 - } 456 - return cmd, nil 457 - } 458 - 459 - func replaceAliasTokenInCommand(cmd, alias, baseDir string) (string, error) { 318 + func replaceAliasTokenInCommand(cmd, alias, baseDir string, isBoundary func(byte) bool) (string, error) { 460 319 cmd = strings.TrimSpace(cmd) 461 320 alias = strings.TrimSpace(alias) 462 321 if cmd == "" || alias == "" { ··· 475 334 break 476 335 } 477 336 i += start 478 - if !tokenBoundaryAt(lower, i, len(needle)) { 337 + if !tokenBoundaryAt(lower, i, len(needle), isBoundary) { 479 338 start = i + 1 480 339 continue 481 340 } ··· 538 397 } 539 398 540 399 func containsTokenBoundary(haystack, needle string) bool { 400 + return containsTokenBoundaryWithBoundary(haystack, needle, isBashBoundaryByte) 401 + } 402 + 403 + func containsTokenBoundaryWithBoundary(haystack, needle string, isBoundary func(byte) bool) bool { 541 404 if needle == "" { 542 405 return false 543 406 } ··· 547 410 return false 548 411 } 549 412 i += start 550 - if tokenBoundaryAt(haystack, i, len(needle)) { 413 + if tokenBoundaryAt(haystack, i, len(needle), isBoundary) { 551 414 return true 552 415 } 553 416 start = i + 1 ··· 556 419 557 420 func containsTokenBoundaryFold(haystack, needle string) bool { 558 421 // ASCII-only fold, safe for typical command tokens like "curl". 559 - return containsTokenBoundary(strings.ToLower(haystack), strings.ToLower(needle)) 422 + return containsTokenBoundaryWithBoundary(strings.ToLower(haystack), strings.ToLower(needle), isBashBoundaryByte) 560 423 } 561 424 562 - func tokenBoundaryAt(s string, start, n int) bool { 563 - beforeOK := start == 0 || isBashBoundaryByte(s[start-1]) 425 + func tokenBoundaryAt(s string, start, n int, isBoundary func(byte) bool) bool { 426 + if isBoundary == nil { 427 + isBoundary = isBashBoundaryByte 428 + } 429 + beforeOK := start == 0 || isBoundary(s[start-1]) 564 430 afterIdx := start + n 565 - afterOK := afterIdx >= len(s) || isBashBoundaryByte(s[afterIdx]) 431 + afterOK := afterIdx >= len(s) || isBoundary(s[afterIdx]) 566 432 return beforeOK && afterOK 567 433 } 568 434 ··· 576 442 return true 577 443 case '<', '>', '=', ':', ',', '?', '#': 578 444 return true 579 - case '/': 445 + default: 446 + return os.IsPathSeparator(b) 447 + } 448 + } 449 + 450 + func isPowerShellBoundaryByte(b byte) bool { 451 + if b == '\\' { 580 452 return true 581 - default: 582 - return false 583 453 } 454 + return isBashBoundaryByte(b) 584 455 } 585 456 586 457 type limitedBuffer struct {
+1 -1
tools/builtin/bash_test.go
··· 126 126 127 127 func TestReplaceAliasTokenInCommand(t *testing.T) { 128 128 cache := t.TempDir() 129 - got, err := replaceAliasTokenInCommand("ls file_cache_dir/tmp", "file_cache_dir", cache) 129 + got, err := replaceAliasTokenInCommand("ls file_cache_dir/tmp", "file_cache_dir", cache, isBashBoundaryByte) 130 130 if err != nil { 131 131 t.Fatalf("unexpected err: %v", err) 132 132 }
+164
tools/builtin/powershell.go
··· 1 + package builtin 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "os" 8 + "path" 9 + "strings" 10 + "time" 11 + ) 12 + 13 + type PowerShellTool struct { 14 + Enabled bool 15 + DefaultTimeout time.Duration 16 + MaxOutputBytes int 17 + BaseDirs []string 18 + DenyPaths []string 19 + DenyTokens []string 20 + InjectedEnvVars []string 21 + } 22 + 23 + func NewPowerShellTool(enabled bool, defaultTimeout time.Duration, maxOutputBytes int, baseDirs ...string) *PowerShellTool { 24 + if defaultTimeout <= 0 { 25 + defaultTimeout = 30 * time.Second 26 + } 27 + if maxOutputBytes <= 0 { 28 + maxOutputBytes = 256 * 1024 29 + } 30 + return &PowerShellTool{ 31 + Enabled: enabled, 32 + DefaultTimeout: defaultTimeout, 33 + MaxOutputBytes: maxOutputBytes, 34 + BaseDirs: normalizeBaseDirs(baseDirs), 35 + } 36 + } 37 + 38 + func (t *PowerShellTool) Name() string { return "powershell" } 39 + 40 + func (t *PowerShellTool) Description() string { 41 + return "Runs a PowerShell command and returns stdout/stderr. " + 42 + "For the `cmd` and `cwd`, supports path aliases file_cache_dir and file_state_dir." 43 + } 44 + 45 + func (t *PowerShellTool) ParameterSchema() string { 46 + s := map[string]any{ 47 + "type": "object", 48 + "properties": map[string]any{ 49 + "cmd": map[string]any{ 50 + "type": "string", 51 + "description": "PowerShell command to execute.", 52 + }, 53 + "cwd": map[string]any{ 54 + "type": "string", 55 + "description": "Optional working directory.", 56 + }, 57 + "timeout_seconds": map[string]any{ 58 + "type": "number", 59 + "description": "Optional timeout in seconds.", 60 + }, 61 + }, 62 + "required": []string{"cmd"}, 63 + } 64 + b, _ := json.MarshalIndent(s, "", " ") 65 + return string(b) 66 + } 67 + 68 + func (t *PowerShellTool) Execute(ctx context.Context, params map[string]any) (string, error) { 69 + if !t.Enabled { 70 + return "", fmt.Errorf("powershell tool is disabled (enable via config: tools.powershell.enabled=true)") 71 + } 72 + return executeShellCommand(ctx, params, t.commonConfig(), t.runnerSpec()) 73 + } 74 + 75 + func (t *PowerShellTool) commonConfig() shellToolCommon { 76 + return shellToolCommon{ 77 + ToolName: t.Name(), 78 + DefaultTimeout: t.DefaultTimeout, 79 + MaxOutputBytes: t.MaxOutputBytes, 80 + BaseDirs: append([]string(nil), t.BaseDirs...), 81 + DenyPaths: append([]string(nil), t.DenyPaths...), 82 + DenyTokens: append([]string(nil), t.DenyTokens...), 83 + InjectedEnvVars: append([]string(nil), t.InjectedEnvVars...), 84 + } 85 + } 86 + 87 + func (t *PowerShellTool) runnerSpec() shellRunnerSpec { 88 + return shellRunnerSpec{ 89 + Program: "powershell", 90 + ArgsPrefix: []string{"-NoProfile", "-Command"}, 91 + BuildEnv: powershellToolEnv, 92 + TokenBoundary: isPowerShellBoundaryByte, 93 + MatchDeniedPath: powershellCommandDenied, 94 + TimeoutExitCode: 0, 95 + ReturnObservationOnExitError: true, 96 + ReturnObservationOnTimeout: false, 97 + ReturnObservationOnExecError: false, 98 + } 99 + } 100 + 101 + func powershellToolEnv(injected []string) []string { 102 + env := bashToolEnv(injected) 103 + seen := make(map[string]bool, len(env)) 104 + for _, e := range env { 105 + if i := strings.Index(e, "="); i > 0 { 106 + seen[strings.ToUpper(e[:i])] = true 107 + } 108 + } 109 + for _, key := range []string{ 110 + "APPDATA", 111 + "COMSPEC", 112 + "LOCALAPPDATA", 113 + "PATHEXT", 114 + "PROGRAMDATA", 115 + "PROGRAMFILES", 116 + "PROGRAMFILES(X86)", 117 + "SYSTEMROOT", 118 + "TEMP", 119 + "TMP", 120 + "USERPROFILE", 121 + "WINDIR", 122 + } { 123 + if seen[strings.ToUpper(key)] { 124 + continue 125 + } 126 + value, ok := os.LookupEnv(key) 127 + if !ok || strings.TrimSpace(value) == "" { 128 + continue 129 + } 130 + seen[strings.ToUpper(key)] = true 131 + env = append(env, key+"="+value) 132 + } 133 + return env 134 + } 135 + 136 + func powershellCommandDenied(cmdStr string, denyPaths []string) (string, bool) { 137 + cmdStr = normalizePowerShellToken(cmdStr) 138 + if cmdStr == "" || len(denyPaths) == 0 { 139 + return "", false 140 + } 141 + for _, raw := range denyPaths { 142 + normalized := normalizePowerShellToken(raw) 143 + if normalized == "" { 144 + continue 145 + } 146 + if containsTokenBoundaryWithBoundary(cmdStr, normalized, isPowerShellBoundaryByte) { 147 + return raw, true 148 + } 149 + if base := path.Base(normalized); base != "." && base != "/" && containsTokenBoundaryWithBoundary(cmdStr, base, isPowerShellBoundaryByte) { 150 + return base, true 151 + } 152 + } 153 + return "", false 154 + } 155 + 156 + func normalizePowerShellToken(raw string) string { 157 + value := strings.TrimSpace(raw) 158 + if value == "" { 159 + return "" 160 + } 161 + value = strings.ReplaceAll(value, "\\", "/") 162 + value = strings.ToLower(value) 163 + return path.Clean(value) 164 + }
+79
tools/builtin/powershell_test.go
··· 1 + package builtin 2 + 3 + import ( 4 + "path/filepath" 5 + "strings" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestPowerShellToolEnv_UsesAllowlistedEnvOnly(t *testing.T) { 11 + t.Setenv("PATH", "/usr/bin") 12 + t.Setenv("HOME", "/tmp/mm-home") 13 + t.Setenv("LANG", "C.UTF-8") 14 + t.Setenv("SYSTEMROOT", `C:\Windows`) 15 + t.Setenv("CUSTOM_PS_ALLOWED", "https://example.com") 16 + t.Setenv("MISTER_MORPH_API_KEY", "secret_value_should_not_leak") 17 + 18 + env := strings.Join(powershellToolEnv([]string{"CUSTOM_PS_ALLOWED"}), "\n") 19 + if !strings.Contains(env, "HOME=/tmp/mm-home") { 20 + t.Fatalf("expected HOME to be preserved, got %q", env) 21 + } 22 + if !strings.Contains(env, "LANG=C.UTF-8") { 23 + t.Fatalf("expected LANG to be preserved, got %q", env) 24 + } 25 + if !strings.Contains(env, `SYSTEMROOT=C:\Windows`) { 26 + t.Fatalf("expected SYSTEMROOT to be preserved, got %q", env) 27 + } 28 + if !strings.Contains(env, "CUSTOM_PS_ALLOWED=https://example.com") { 29 + t.Fatalf("expected injected env var to be present, got %q", env) 30 + } 31 + if strings.Contains(env, "MISTER_MORPH_API_KEY") || strings.Contains(env, "secret_value_should_not_leak") { 32 + t.Fatalf("powershell env leaked mistermorph secret env: %q", env) 33 + } 34 + } 35 + 36 + func TestPowerShellCommandDenied_NormalizesWindowsPaths(t *testing.T) { 37 + cases := []struct { 38 + name string 39 + cmd string 40 + deny string 41 + want bool 42 + }{ 43 + {name: "basename with backslashes", cmd: `Get-Content C:\tmp\config.yaml`, deny: "config.yaml", want: true}, 44 + {name: "full path with backslashes", cmd: `Get-Content C:\tmp\config.yaml`, deny: `C:\tmp\config.yaml`, want: true}, 45 + {name: "case insensitive", cmd: `Get-Content C:\TMP\CONFIG.YAML`, deny: "config.yaml", want: true}, 46 + {name: "nonmatch suffix", cmd: `Get-Content C:\tmp\config.yaml.bak`, deny: "config.yaml", want: false}, 47 + } 48 + 49 + for _, tc := range cases { 50 + t.Run(tc.name, func(t *testing.T) { 51 + _, ok := powershellCommandDenied(tc.cmd, []string{tc.deny}) 52 + if ok != tc.want { 53 + t.Fatalf("powershellCommandDenied(%q, %q) = %v, want %v", tc.cmd, tc.deny, ok, tc.want) 54 + } 55 + }) 56 + } 57 + } 58 + 59 + func TestPrepareShellInvocation_PowerShellAliasSupportsBackslashes(t *testing.T) { 60 + cache := t.TempDir() 61 + 62 + inv, err := prepareShellInvocation(map[string]any{ 63 + "cmd": `Get-Content file_cache_dir\notes.txt`, 64 + }, shellToolCommon{ 65 + ToolName: "powershell", 66 + DefaultTimeout: 5 * time.Second, 67 + BaseDirs: []string{cache}, 68 + }, shellRunnerSpec{ 69 + TokenBoundary: isPowerShellBoundaryByte, 70 + }) 71 + if err != nil { 72 + t.Fatalf("prepareShellInvocation() error = %v", err) 73 + } 74 + 75 + want := `Get-Content ` + filepath.Clean(cache) + `\notes.txt` 76 + if inv.Command != want { 77 + t.Fatalf("inv.Command = %q, want %q", inv.Command, want) 78 + } 79 + }
+305
tools/builtin/shell_runner.go
··· 1 + package builtin 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "io" 8 + "os/exec" 9 + "path/filepath" 10 + "strings" 11 + "sync" 12 + "time" 13 + 14 + "github.com/quailyquaily/mistermorph/internal/pathutil" 15 + ) 16 + 17 + type shellExecutionPayload struct { 18 + ExitCode int `json:"exit_code"` 19 + StdoutTruncated bool `json:"stdout_truncated"` 20 + StderrTruncated bool `json:"stderr_truncated"` 21 + Stdout string `json:"stdout"` 22 + Stderr string `json:"stderr"` 23 + } 24 + 25 + type shellToolCommon struct { 26 + ToolName string 27 + DefaultTimeout time.Duration 28 + MaxOutputBytes int 29 + BaseDirs []string 30 + DenyPaths []string 31 + DenyTokens []string 32 + InjectedEnvVars []string 33 + } 34 + 35 + type shellInvocation struct { 36 + Command string 37 + CWD string 38 + Timeout time.Duration 39 + } 40 + 41 + type shellRunnerSpec struct { 42 + Program string 43 + ArgsPrefix []string 44 + BuildEnv func(injected []string) []string 45 + TokenBoundary func(byte) bool 46 + MatchDeniedPath func(cmd string, denyPaths []string) (string, bool) 47 + StreamOutput bool 48 + EmitChunk func(ctx context.Context, stream, text string) 49 + TimeoutExitCode int 50 + ReturnObservationOnExitError bool 51 + ReturnObservationOnTimeout bool 52 + ReturnObservationOnExecError bool 53 + } 54 + 55 + type shellFailureKind int 56 + 57 + const ( 58 + shellFailureNone shellFailureKind = iota 59 + shellFailureExit 60 + shellFailureTimeout 61 + shellFailureExec 62 + ) 63 + 64 + func prepareShellInvocation(params map[string]any, common shellToolCommon, spec shellRunnerSpec) (shellInvocation, error) { 65 + cmdStr, _ := params["cmd"].(string) 66 + cmdStr = strings.TrimSpace(cmdStr) 67 + if cmdStr == "" { 68 + return shellInvocation{}, fmt.Errorf("missing required param: cmd") 69 + } 70 + 71 + var err error 72 + cmdStr, err = expandShellPathAliases(common.BaseDirs, cmdStr, spec.TokenBoundary) 73 + if err != nil { 74 + return shellInvocation{}, err 75 + } 76 + 77 + if spec.MatchDeniedPath != nil { 78 + if offending, ok := spec.MatchDeniedPath(cmdStr, common.DenyPaths); ok { 79 + return shellInvocation{}, fmt.Errorf("%s command references denied path %q (configure via tools.%s.deny_paths)", common.ToolName, offending, common.ToolName) 80 + } 81 + } 82 + if offending, ok := bashCommandDeniedTokens(cmdStr, common.DenyTokens); ok { 83 + return shellInvocation{}, fmt.Errorf("%s command references denied token %q", common.ToolName, offending) 84 + } 85 + 86 + cwd, _ := params["cwd"].(string) 87 + cwd, err = resolveShellCWD(common.BaseDirs, strings.TrimSpace(cwd)) 88 + if err != nil { 89 + return shellInvocation{}, err 90 + } 91 + 92 + timeout := common.DefaultTimeout 93 + if v, ok := params["timeout_seconds"]; ok { 94 + if secs, ok := asFloat64(v); ok && secs > 0 { 95 + timeout = time.Duration(secs * float64(time.Second)) 96 + } 97 + } 98 + 99 + return shellInvocation{ 100 + Command: cmdStr, 101 + CWD: cwd, 102 + Timeout: timeout, 103 + }, nil 104 + } 105 + 106 + func executeShellCommand(ctx context.Context, params map[string]any, common shellToolCommon, spec shellRunnerSpec) (string, error) { 107 + inv, err := prepareShellInvocation(params, common, spec) 108 + if err != nil { 109 + return "", err 110 + } 111 + payload, failureKind, err := runShellCommand(ctx, common, spec, inv) 112 + if err != nil { 113 + observation := formatShellObservation(payload) 114 + switch failureKind { 115 + case shellFailureExit: 116 + if spec.ReturnObservationOnExitError { 117 + return observation, err 118 + } 119 + case shellFailureTimeout: 120 + if spec.ReturnObservationOnTimeout { 121 + return observation, err 122 + } 123 + case shellFailureExec: 124 + if spec.ReturnObservationOnExecError { 125 + return observation, err 126 + } 127 + } 128 + return "", err 129 + } 130 + return formatShellObservation(payload), nil 131 + } 132 + 133 + func runShellCommand(ctx context.Context, common shellToolCommon, spec shellRunnerSpec, inv shellInvocation) (shellExecutionPayload, shellFailureKind, error) { 134 + runCtx, cancel := context.WithTimeout(ctx, inv.Timeout) 135 + defer cancel() 136 + 137 + args := append(append([]string(nil), spec.ArgsPrefix...), inv.Command) 138 + cmd := exec.CommandContext(runCtx, spec.Program, args...) 139 + if inv.CWD != "" { 140 + cmd.Dir = inv.CWD 141 + } 142 + if spec.BuildEnv != nil { 143 + cmd.Env = spec.BuildEnv(common.InjectedEnvVars) 144 + } 145 + 146 + var stdout limitedBuffer 147 + var stderr limitedBuffer 148 + stdout.Limit = common.MaxOutputBytes 149 + stderr.Limit = common.MaxOutputBytes 150 + stdoutPipe, err := cmd.StdoutPipe() 151 + if err != nil { 152 + return shellExecutionPayload{}, shellFailureExec, err 153 + } 154 + stderrPipe, err := cmd.StderrPipe() 155 + if err != nil { 156 + return shellExecutionPayload{}, shellFailureExec, err 157 + } 158 + if err := cmd.Start(); err != nil { 159 + return shellExecutionPayload{}, shellFailureExec, err 160 + } 161 + 162 + var streamWG sync.WaitGroup 163 + var stdoutReadErr error 164 + var stderrReadErr error 165 + streamWG.Add(2) 166 + go func() { 167 + defer streamWG.Done() 168 + stdoutReadErr = readShellPipe(runCtx, "stdout", stdoutPipe, &stdout, streamEmitter(spec, ctx)) 169 + }() 170 + go func() { 171 + defer streamWG.Done() 172 + stderrReadErr = readShellPipe(runCtx, "stderr", stderrPipe, &stderr, streamEmitter(spec, ctx)) 173 + }() 174 + 175 + err = cmd.Wait() 176 + streamWG.Wait() 177 + if err == nil { 178 + if stdoutReadErr != nil { 179 + err = stdoutReadErr 180 + } else if stderrReadErr != nil { 181 + err = stderrReadErr 182 + } 183 + } 184 + 185 + exitCode := 0 186 + failureKind := shellFailureNone 187 + if err != nil { 188 + switch { 189 + case runCtx.Err() != nil: 190 + exitCode = spec.TimeoutExitCode 191 + failureKind = shellFailureTimeout 192 + err = fmt.Errorf("%s timed out after %s", common.ToolName, inv.Timeout) 193 + case isExitError(err): 194 + exitCode = exitCodeFromError(err) 195 + failureKind = shellFailureExit 196 + default: 197 + exitCode = -1 198 + failureKind = shellFailureExec 199 + } 200 + } 201 + 202 + payload := shellExecutionPayload{ 203 + ExitCode: exitCode, 204 + StdoutTruncated: stdout.Truncated, 205 + StderrTruncated: stderr.Truncated, 206 + Stdout: string(bytes.ToValidUTF8(stdout.Bytes(), []byte("\n[non-utf8 output]\n"))), 207 + Stderr: string(bytes.ToValidUTF8(stderr.Bytes(), []byte("\n[non-utf8 output]\n"))), 208 + } 209 + if err == nil { 210 + return payload, shellFailureNone, nil 211 + } 212 + if failureKind == shellFailureExit { 213 + err = fmt.Errorf("%s exited with code %d", common.ToolName, exitCode) 214 + } 215 + return payload, failureKind, err 216 + } 217 + 218 + func readShellPipe(ctx context.Context, stream string, r io.Reader, dst *limitedBuffer, emit func(stream, text string)) error { 219 + if r == nil || dst == nil { 220 + return nil 221 + } 222 + buf := make([]byte, 4096) 223 + for { 224 + n, err := r.Read(buf) 225 + if n > 0 { 226 + chunk := append([]byte(nil), buf[:n]...) 227 + _, _ = dst.Write(chunk) 228 + if emit != nil { 229 + text := string(bytes.ToValidUTF8(chunk, []byte("\n[non-utf8 output]\n"))) 230 + if strings.TrimSpace(text) != "" { 231 + emit(stream, text) 232 + } 233 + } 234 + } 235 + if err == nil { 236 + continue 237 + } 238 + if err == io.EOF { 239 + return nil 240 + } 241 + if isBenignCommandStreamReadError(err) { 242 + return nil 243 + } 244 + select { 245 + case <-ctx.Done(): 246 + return nil 247 + default: 248 + return err 249 + } 250 + } 251 + } 252 + 253 + func streamEmitter(spec shellRunnerSpec, ctx context.Context) func(stream, text string) { 254 + if !spec.StreamOutput || spec.EmitChunk == nil { 255 + return nil 256 + } 257 + return func(stream, text string) { 258 + spec.EmitChunk(ctx, stream, text) 259 + } 260 + } 261 + 262 + func resolveShellCWD(baseDirs []string, raw string) (string, error) { 263 + raw = strings.TrimSpace(raw) 264 + if raw == "" { 265 + return "", nil 266 + } 267 + alias, rest := detectWritePathAlias(raw) 268 + if alias == "" { 269 + return pathutil.ExpandHomePath(raw), nil 270 + } 271 + base := selectBaseForAlias(baseDirs, alias) 272 + if strings.TrimSpace(base) == "" { 273 + return "", fmt.Errorf("base dir %s is not configured", alias) 274 + } 275 + rest = strings.TrimLeft(strings.TrimSpace(rest), "/\\") 276 + if rest == "" { 277 + return filepath.Clean(base), nil 278 + } 279 + return filepath.Clean(filepath.Join(base, rest)), nil 280 + } 281 + 282 + func expandShellPathAliases(baseDirs []string, cmd string, isBoundary func(byte) bool) (string, error) { 283 + var err error 284 + cmd, err = replaceAliasTokenInCommand(cmd, "file_cache_dir", selectBaseForAlias(baseDirs, "file_cache_dir"), isBoundary) 285 + if err != nil { 286 + return "", err 287 + } 288 + cmd, err = replaceAliasTokenInCommand(cmd, "file_state_dir", selectBaseForAlias(baseDirs, "file_state_dir"), isBoundary) 289 + if err != nil { 290 + return "", err 291 + } 292 + return cmd, nil 293 + } 294 + 295 + func formatShellObservation(payload shellExecutionPayload) string { 296 + var out strings.Builder 297 + fmt.Fprintf(&out, "exit_code: %d\n", payload.ExitCode) 298 + fmt.Fprintf(&out, "stdout_truncated: %t\n", payload.StdoutTruncated) 299 + fmt.Fprintf(&out, "stderr_truncated: %t\n", payload.StderrTruncated) 300 + out.WriteString("stdout:\n") 301 + out.WriteString(payload.Stdout) 302 + out.WriteString("\n\nstderr:\n") 303 + out.WriteString(payload.Stderr) 304 + return out.String() 305 + }
+62
tools/builtin/shell_runner_test.go
··· 1 + package builtin 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestExecuteShellCommand_ReturnsObservationOnExitErrorWhenConfigured(t *testing.T) { 11 + out, err := executeShellCommand(context.Background(), map[string]any{ 12 + "cmd": "printf 'boom'; exit 7", 13 + }, shellToolCommon{ 14 + ToolName: "powershell", 15 + DefaultTimeout: 5 * time.Second, 16 + MaxOutputBytes: 4096, 17 + }, shellRunnerSpec{ 18 + Program: "bash", 19 + ArgsPrefix: []string{"-lc"}, 20 + BuildEnv: bashToolEnv, 21 + MatchDeniedPath: bashCommandDenied, 22 + ReturnObservationOnExitError: true, 23 + }) 24 + if err == nil { 25 + t.Fatalf("expected error, got nil (out=%q)", out) 26 + } 27 + if !strings.Contains(err.Error(), "powershell exited with code 7") { 28 + t.Fatalf("error = %v, want powershell exited with code 7", err) 29 + } 30 + if !strings.Contains(out, "exit_code: 7") { 31 + t.Fatalf("expected observation to contain exit_code: 7, got %q", out) 32 + } 33 + if !strings.Contains(out, "boom") { 34 + t.Fatalf("expected observation to contain stdout payload, got %q", out) 35 + } 36 + } 37 + 38 + func TestExecuteShellCommand_DropsObservationOnTimeoutWhenConfigured(t *testing.T) { 39 + out, err := executeShellCommand(context.Background(), map[string]any{ 40 + "cmd": "sleep 1", 41 + "timeout_seconds": 0.05, 42 + }, shellToolCommon{ 43 + ToolName: "powershell", 44 + DefaultTimeout: 5 * time.Second, 45 + MaxOutputBytes: 4096, 46 + }, shellRunnerSpec{ 47 + Program: "bash", 48 + ArgsPrefix: []string{"-lc"}, 49 + BuildEnv: bashToolEnv, 50 + MatchDeniedPath: bashCommandDenied, 51 + ReturnObservationOnTimeout: false, 52 + }) 53 + if err == nil { 54 + t.Fatalf("expected timeout error, got nil (out=%q)", out) 55 + } 56 + if !strings.Contains(err.Error(), "powershell timed out after") { 57 + t.Fatalf("error = %v, want powershell timed out after", err) 58 + } 59 + if out != "" { 60 + t.Fatalf("expected timeout path to drop observation, got %q", out) 61 + } 62 + }
+6 -6
tools/builtin/url_fetch.go
··· 182 182 if v, ok := params["method"]; ok { 183 183 s, ok := v.(string) 184 184 if !ok { 185 - return "", fmt.Errorf("invalid param: method must be a string (for more complex requests, use the bash tool with curl)") 185 + return "", fmt.Errorf("invalid param: method must be a string (for more complex requests, use curl)") 186 186 } 187 187 s = strings.ToUpper(strings.TrimSpace(s)) 188 188 if s != "" { ··· 192 192 switch method { 193 193 case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: 194 194 default: 195 - return "", fmt.Errorf("unsupported method: %s (url_fetch supports GET, POST, PUT, PATCH, DELETE; for other methods use the bash tool with curl)", method) 195 + return "", fmt.Errorf("unsupported method: %s (url_fetch supports GET, POST, PUT, PATCH, DELETE; for other methods use curl)", method) 196 196 } 197 197 198 198 timeout := t.Timeout ··· 234 234 inferredContentType = "application/json" 235 235 bodyBytes, err := json.Marshal(x) 236 236 if err != nil { 237 - return "", fmt.Errorf("invalid param: body must be a string or JSON-serializable value (for more complex requests, use the bash tool with curl): %w", err) 237 + return "", fmt.Errorf("invalid param: body must be a string or JSON-serializable value (for more complex requests, use curl): %w", err) 238 238 } 239 239 bodyReader = bytes.NewReader(bodyBytes) 240 240 } ··· 242 242 } 243 243 if bodyProvided && method != http.MethodPost && method != http.MethodPut { 244 244 if method != http.MethodPatch { 245 - return "", fmt.Errorf("request body is only supported for POST/PUT/PATCH in url_fetch (use the bash tool with curl for %s with a body)", method) 245 + return "", fmt.Errorf("request body is only supported for POST/PUT/PATCH in url_fetch (use curl for %s with a body)", method) 246 246 } 247 247 } 248 248 ··· 306 306 headersProvided = true 307 307 m, ok := hdrs.(map[string]any) 308 308 if !ok { 309 - return "", fmt.Errorf("invalid param: headers must be an object of string values (for more complex requests, use the bash tool with curl)") 309 + return "", fmt.Errorf("invalid param: headers must be an object of string values (for more complex requests, use curl)") 310 310 } 311 311 if authProfileID != "" && !binding.AllowUserHeaders && len(m) > 0 { 312 312 return "", fmt.Errorf("headers are not allowed when using auth_profile %q", authProfileID) ··· 318 318 } 319 319 value, ok := v.(string) 320 320 if !ok { 321 - return "", fmt.Errorf("invalid header %q: value must be a string (for more complex requests, use the bash tool with curl)", key) 321 + return "", fmt.Errorf("invalid header %q: value must be a string (for more complex requests, use curl)", key) 322 322 } 323 323 value = strings.TrimSpace(value) 324 324 if isDeniedUserHeader(key) {
+6
web/console/src/i18n/index.js
··· 454 454 settings_tool_note_web_search: "Allow internet search through the configured search tool.", 455 455 settings_tool_bash: "bash", 456 456 settings_tool_note_bash: "Allow local shell execution.", 457 + settings_tool_powershell: "powershell", 458 + settings_tool_note_powershell: "Allow local PowerShell execution.", 457 459 settings_console_title: "Console", 458 460 settings_console_runtime_title: "Managed Runtimes", 459 461 settings_console_runtime_hint: "Enable or disable the runtimes hosted by this console in {path}.", ··· 1037 1039 settings_tool_note_web_search: "允许 agent 通过搜索工具访问互联网。", 1038 1040 settings_tool_bash: "bash", 1039 1041 settings_tool_note_bash: "允许本地 shell 执行。", 1042 + settings_tool_powershell: "powershell", 1043 + settings_tool_note_powershell: "允许本地 PowerShell 执行。", 1040 1044 settings_console_title: "Console", 1041 1045 settings_console_runtime_title: "托管 Runtime", 1042 1046 settings_console_runtime_hint: "在 {path} 里开启或关闭当前 Console 托管的 runtime。", ··· 1610 1614 settings_tool_note_web_search: "検索ツール経由のインターネット検索を許可します。", 1611 1615 settings_tool_bash: "bash", 1612 1616 settings_tool_note_bash: "ローカル shell 実行を許可します。", 1617 + settings_tool_powershell: "powershell", 1618 + settings_tool_note_powershell: "ローカル PowerShell 実行を許可します。", 1613 1619 settings_console_title: "Console", 1614 1620 settings_console_runtime_title: "Managed Runtime", 1615 1621 settings_console_runtime_hint: "この Console が {path} で管理する runtime を有効化または無効化します。",
+5
web/console/src/views/SettingsView.js
··· 53 53 { id: "url_fetch", titleKey: "settings_tool_url_fetch", noteKey: "settings_tool_note_url_fetch" }, 54 54 { id: "web_search", titleKey: "settings_tool_web_search", noteKey: "settings_tool_note_web_search" }, 55 55 { id: "bash", titleKey: "settings_tool_bash", noteKey: "settings_tool_note_bash" }, 56 + { id: "powershell", titleKey: "settings_tool_powershell", noteKey: "settings_tool_note_powershell" }, 56 57 ]; 57 58 58 59 const MANAGED_RUNTIME_ITEMS = [ ··· 217 218 url_fetch: !!state.tools.url_fetch, 218 219 web_search: !!state.tools.web_search, 219 220 bash: !!state.tools.bash, 221 + powershell: !!state.tools.powershell, 220 222 }, 221 223 }); 222 224 } ··· 363 365 url_fetch: true, 364 366 web_search: true, 365 367 bash: true, 368 + powershell: false, 366 369 }, 367 370 managedRuntimes: { 368 371 telegram: false, ··· 690 693 state.tools.url_fetch = toolEnabledValue(tools.url_fetch); 691 694 state.tools.web_search = toolEnabledValue(tools.web_search); 692 695 state.tools.bash = toolEnabledValue(tools.bash); 696 + state.tools.powershell = toolEnabledValue(tools.powershell); 693 697 llmEnvManaged.value = llmEnvManagedPayload; 694 698 695 699 agentValidationVisible.value = false; ··· 1002 1006 url_fetch: { enabled: state.tools.url_fetch }, 1003 1007 web_search: { enabled: state.tools.web_search }, 1004 1008 bash: { enabled: state.tools.bash }, 1009 + powershell: { enabled: state.tools.powershell }, 1005 1010 }; 1006 1011 if (target === "llm") { 1007 1012 return { llm: buildLLMSettingsPayload() };
+17 -2
web/vitepress/docs/guide/built-in-tools.md
··· 16 16 17 17 | Group | When available | Tools | 18 18 |---|---|---| 19 - | Static tools | Available from config alone | `read_file`, `write_file`, `bash`, `url_fetch`, `web_search`, `contacts_send` | 19 + | Static tools | Available from config alone | `read_file`, `write_file`, `bash`, `powershell`, `url_fetch`, `web_search`, `contacts_send` | 20 20 | Engine tools | Available when an agent engine is assembled for a run | `spawn`, `acp_spawn` | 21 21 | Runtime tools | Available when the LLM or required context is available | `plan_create`, `todo_update` | 22 22 | Channel-specific tools | Available when the current channel is Telegram / Slack or another concrete channel runtime | `telegram_send_voice`, `telegram_send_photo`, `telegram_send_file`, `message_react` | 23 23 24 24 ## Static Tools (config-driven) 25 + 26 + Shell defaults are platform-specific: 27 + 28 + - Linux/macOS: `bash` enabled by default, `powershell` disabled by default. 29 + - Windows: `powershell` enabled by default, `bash` disabled by default. 30 + - You can still override either one explicitly with `tools.<name>.enabled`. 25 31 26 32 ### `read_file` 27 33 ··· 39 45 40 46 Executes local `bash` commands to call existing CLIs, run one-off conversions, execute scripts, or inspect the local environment. 41 47 42 - - Key limits: can be disabled via `tools.bash.enabled`; restricted by `deny_paths` and internal deny-token rules; child processes inherit only an allowlisted environment. 48 + - Key limits: restricted by `deny_paths` and internal deny-token rules; child processes inherit only an allowlisted environment. 43 49 - Current isolated-execution behavior: accepts `run_in_subtask=true` and runs the command inside one direct boundary; when the current runtime exposes a stream sink, stdout/stderr chunks can appear in the preview stream before the command exits. 50 + 51 + ### `powershell` 52 + 53 + Executes local PowerShell commands. This is the Windows-oriented shell tool for calling existing CLIs, running scripts, and inspecting the local environment. 54 + 55 + - Key limits: can be disabled via `tools.powershell.enabled`; restricted by `deny_paths` and internal deny-token rules; child processes inherit only an allowlisted environment. 56 + - Current behavior: supports the same `file_cache_dir` / `file_state_dir` aliases as `bash`, including backslash path forms such as `file_cache_dir\foo.txt`. 57 + - Current gap vs `bash`: does not currently expose `run_in_subtask=true`. 44 58 45 59 ### `url_fetch` 46 60 ··· 147 161 spawn: ... 148 162 acp_spawn: ... 149 163 bash: ... 164 + powershell: ... 150 165 url_fetch: ... 151 166 web_search: ... 152 167 contacts_send: ...
+11
web/vitepress/docs/guide/config-reference.md
··· 92 92 93 93 The Console Setup / Settings UI and `/api/settings/agent` reuse the same nested shape under `tools.<name>.enabled`. 94 94 95 + Shell defaults are platform-specific: 96 + 97 + - Linux/macOS: `tools.bash.enabled=true`, `tools.powershell.enabled=false` 98 + - Windows: `tools.bash.enabled=false`, `tools.powershell.enabled=true` 99 + - You can still override either value explicitly. 100 + 95 101 - `tools.read_file.max_bytes` 96 102 - `tools.read_file.deny_paths` 97 103 - `tools.write_file.enabled` ··· 114 120 - `tools.bash.max_output_bytes` 115 121 - `tools.bash.deny_paths` 116 122 - `tools.bash.injected_env_vars` 123 + - `tools.powershell.enabled` 124 + - `tools.powershell.timeout` 125 + - `tools.powershell.max_output_bytes` 126 + - `tools.powershell.deny_paths` 127 + - `tools.powershell.injected_env_vars` 117 128 118 129 ## MCP 119 130
+1
web/vitepress/docs/guide/env-vars-reference.md
··· 26 26 27 27 - `llm.api_key` -> `MISTER_MORPH_LLM_API_KEY` 28 28 - `tools.bash.enabled` -> `MISTER_MORPH_TOOLS_BASH_ENABLED` 29 + - `tools.powershell.enabled` -> `MISTER_MORPH_TOOLS_POWERSHELL_ENABLED` 29 30 - `tools.spawn.enabled` -> `MISTER_MORPH_TOOLS_SPAWN_ENABLED` 30 31 - `mcp.servers` -> `MISTER_MORPH_MCP_SERVERS` 31 32
+2
web/vitepress/docs/guide/prompt-architecture.md
··· 55 55 56 56 After the final system prompt is ready, the main Agent still arranges a message stack in the request. 57 57 58 + Runtime metadata is injected as a user-role JSON envelope under `mister_morph_meta`. Typical fields include trigger or correlation data when present, runtime clock fields such as `now_utc` / `now_local` / `now_local_weekday`, and host facts such as `host_os`. 59 + 58 60 The order can be understood like this: 59 61 60 62 ```text
+17 -2
web/vitepress/docs/ja/guide/built-in-tools.md
··· 16 16 17 17 | 分類 | いつ使えるか | ツール | 18 18 |---|---|---| 19 - | 静的ツール | 設定だけで利用可能 | `read_file`、`write_file`、`bash`、`url_fetch`、`web_search`、`contacts_send` | 19 + | 静的ツール | 設定だけで利用可能 | `read_file`、`write_file`、`bash`、`powershell`、`url_fetch`、`web_search`、`contacts_send` | 20 20 | Engine ツール | agent engine が 1 回組み上がると利用可能 | `spawn`、`acp_spawn` | 21 21 | ランタイムツール | LLM や必要な文脈が利用可能なとき | `plan_create`、`todo_update` | 22 22 | チャネル専用ツール | 現在の Channel が Telegram / Slack などの具体的 runtime のとき | `telegram_send_voice`、`telegram_send_photo`、`telegram_send_file`、`message_react` | 23 23 24 24 ## 静的ツール(設定駆動) 25 + 26 + Shell のデフォルト有効状態はプラットフォームごとに分かれます。 27 + 28 + - Linux / macOS: `bash` がデフォルトで有効、`powershell` はデフォルトで無効です。 29 + - Windows: `powershell` がデフォルトで有効、`bash` はデフォルトで無効です。 30 + - どちらも `tools.<name>.enabled` で明示的に上書きできます。 25 31 26 32 ### `read_file` 27 33 ··· 39 45 40 46 ローカルの `bash` コマンドを実行します。既存 CLI の利用、一時的な変換処理、スクリプト実行、環境確認に使います。 41 47 42 - - 主な制約: `tools.bash.enabled` で無効化できます。`deny_paths` と内部 deny-token ルールの制約を受け、子プロセスには許可済み環境変数だけが渡されます。 48 + - 主な制約: `deny_paths` と内部 deny-token ルールの制約を受け、子プロセスには許可済み環境変数だけが渡されます。 43 49 - 現在の分離実行挙動: `run_in_subtask=true` を明示すると、コマンドは 1 層の direct boundary で実行されます。runtime が stream sink を持つ場合、stdout/stderr はコマンド終了前にプレビューへ流れます。 50 + 51 + ### `powershell` 52 + 53 + ローカルの PowerShell コマンドを実行します。Windows 向けの shell ツールで、既存 CLI の呼び出し、スクリプト実行、ローカル環境確認に使います。 54 + 55 + - 主な制約: `tools.powershell.enabled` で無効化できます。`deny_paths` と内部 deny-token ルールの制約を受け、子プロセスには許可済み環境変数だけが渡されます。 56 + - 現在の挙動: `bash` と同じ `file_cache_dir` / `file_state_dir` エイリアスを使えます。`file_cache_dir\foo.txt` のようなバックスラッシュ形式にも対応します。 57 + - `bash` との差分: 現時点では `run_in_subtask=true` は使えません。 44 58 45 59 ### `url_fetch` 46 60 ··· 147 161 spawn: ... 148 162 acp_spawn: ... 149 163 bash: ... 164 + powershell: ... 150 165 url_fetch: ... 151 166 web_search: ... 152 167 contacts_send: ...
+11
web/vitepress/docs/ja/guide/config-reference.md
··· 92 92 93 93 Console の Setup / Settings 画面と `/api/settings/agent` も、`tools.<name>.enabled` の同じ入れ子構造を使います。 94 94 95 + Shell のデフォルト値はプラットフォームごとに分かれます。 96 + 97 + - Linux / macOS: `tools.bash.enabled=true`、`tools.powershell.enabled=false` 98 + - Windows: `tools.bash.enabled=false`、`tools.powershell.enabled=true` 99 + - どちらも明示的に上書きできます。 100 + 95 101 - `tools.read_file.max_bytes` 96 102 - `tools.read_file.deny_paths` 97 103 - `tools.write_file.enabled` ··· 114 120 - `tools.bash.max_output_bytes` 115 121 - `tools.bash.deny_paths` 116 122 - `tools.bash.injected_env_vars` 123 + - `tools.powershell.enabled` 124 + - `tools.powershell.timeout` 125 + - `tools.powershell.max_output_bytes` 126 + - `tools.powershell.deny_paths` 127 + - `tools.powershell.injected_env_vars` 117 128 118 129 ## MCP 119 130
+1
web/vitepress/docs/ja/guide/env-vars-reference.md
··· 26 26 27 27 - `llm.api_key` -> `MISTER_MORPH_LLM_API_KEY` 28 28 - `tools.bash.enabled` -> `MISTER_MORPH_TOOLS_BASH_ENABLED` 29 + - `tools.powershell.enabled` -> `MISTER_MORPH_TOOLS_POWERSHELL_ENABLED` 29 30 - `tools.spawn.enabled` -> `MISTER_MORPH_TOOLS_SPAWN_ENABLED` 30 31 - `mcp.servers` -> `MISTER_MORPH_MCP_SERVERS` 31 32
+2
web/vitepress/docs/ja/guide/prompt-architecture.md
··· 55 55 56 56 最終 system prompt の準備ができたあと、主 Agent はリクエスト内でメッセージ列も編成します。 57 57 58 + 実行時 metadata は、`mister_morph_meta` を外側 key に持つ user ロールの JSON envelope として注入されます。代表的な項目は、存在する場合の trigger / correlation 情報、`now_utc` / `now_local` / `now_local_weekday` のような runtime clock の値、そして `host_os` のようなホスト環境情報です。 59 + 58 60 順序は次のように理解できます。 59 61 60 62 ```text
+17 -2
web/vitepress/docs/zh/guide/built-in-tools.md
··· 16 16 17 17 | 分组 | 什么时候出现 | 工具 | 18 18 |---|---|---| 19 - | 静态工具 | 仅靠配置即可创建 | `read_file`、`write_file`、`bash`、`url_fetch`、`web_search`、`contacts_send` | 19 + | 静态工具 | 仅靠配置即可创建 | `read_file`、`write_file`、`bash`、`powershell`、`url_fetch`、`web_search`、`contacts_send` | 20 20 | Engine 工具 | 某次 agent engine 装配完成后可用 | `spawn`、`acp_spawn` | 21 21 | 运行时工具 | 当 LLM 或者依赖的上下文可用时 | `plan_create`、`todo_update` | 22 22 | 通道专属工具 | 当前正在使用 Telegram / Slack 等具体 Channel | `telegram_send_voice`、`telegram_send_photo`、`telegram_send_file`、`message_react` | 23 23 24 24 ## 静态工具(配置驱动) 25 + 26 + Shell 的默认启用状态按平台区分: 27 + 28 + - Linux / macOS:默认启用 `bash`,默认关闭 `powershell`。 29 + - Windows:默认启用 `powershell`,默认关闭 `bash`。 30 + - 两者都可以继续通过 `tools.<name>.enabled` 显式覆盖。 25 31 26 32 ### `read_file` 27 33 ··· 39 45 40 46 执行本地 `bash` 命令,调用已有 CLI、做一次性格式转换、跑脚本或查询系统信息。 41 47 42 - 关键限制:可通过 `tools.bash.enabled` 关闭;会受 `deny_paths` 和内部 deny-token 规则限制;`bash` 启动的子进程只继承白名单环境变量。 48 + 关键限制:会受 `deny_paths` 和内部 deny-token 规则限制;`bash` 启动的子进程只继承白名单环境变量。 43 49 当前隔离执行行为:支持显式 `run_in_subtask=true`,把命令放进一层 direct boundary 里执行;如果当前 runtime 暴露了流式 sink,stdout/stderr 会在命令结束前先出现在预览流里。 50 + 51 + ### `powershell` 52 + 53 + 执行本地 PowerShell 命令。它是面向 Windows 的 shell 工具,适合调用已有 CLI、跑脚本或查询本地环境。 54 + 55 + 关键限制:可通过 `tools.powershell.enabled` 关闭;会受 `deny_paths` 和内部 deny-token 规则限制;子进程只继承白名单环境变量。 56 + 当前行为:支持和 `bash` 相同的 `file_cache_dir` / `file_state_dir` 别名,也支持 `file_cache_dir\foo.txt` 这类反斜杠路径写法。 57 + 当前差异:暂不支持 `run_in_subtask=true`。 44 58 45 59 ### `url_fetch` 46 60 ··· 150 164 spawn: ... 151 165 acp_spawn: ... 152 166 bash: ... 167 + powershell: ... 153 168 url_fetch: ... 154 169 web_search: ... 155 170 contacts_send: ...
+11
web/vitepress/docs/zh/guide/config-reference.md
··· 104 104 105 105 Console 的 Setup / Settings 页面,以及 `/api/settings/agent`,复用同一套 `tools.<name>.enabled` 嵌套结构。 106 106 107 + Shell 默认值按平台区分: 108 + 109 + - Linux / macOS:`tools.bash.enabled=true`,`tools.powershell.enabled=false` 110 + - Windows:`tools.bash.enabled=false`,`tools.powershell.enabled=true` 111 + - 两者都可以继续显式覆盖。 112 + 107 113 | 字段 | 含义 | 108 114 |---|---| 109 115 | `tools.read_file.max_bytes` | `read_file` 单次最大读取字节数。 | ··· 128 134 | `tools.bash.max_output_bytes` | `bash` 每个输出流最大保留字节数。 | 129 135 | `tools.bash.deny_paths` | `bash` 命令中禁止引用的路径列表。 | 130 136 | `tools.bash.injected_env_vars` | 额外注入给 `bash` 子进程的环境变量白名单。 | 137 + | `tools.powershell.enabled` | 是否启用 `powershell`(高风险能力)。 | 138 + | `tools.powershell.timeout` | `powershell` 单次执行超时。 | 139 + | `tools.powershell.max_output_bytes` | `powershell` 每个输出流最大保留字节数。 | 140 + | `tools.powershell.deny_paths` | `powershell` 命令中禁止引用的路径列表。 | 141 + | `tools.powershell.injected_env_vars` | 额外注入给 `powershell` 子进程的环境变量白名单。 | 131 142 132 143 ## MCP 133 144
+1
web/vitepress/docs/zh/guide/env-vars-reference.md
··· 26 26 27 27 - `llm.api_key` -> `MISTER_MORPH_LLM_API_KEY` 28 28 - `tools.bash.enabled` -> `MISTER_MORPH_TOOLS_BASH_ENABLED` 29 + - `tools.powershell.enabled` -> `MISTER_MORPH_TOOLS_POWERSHELL_ENABLED` 29 30 - `tools.spawn.enabled` -> `MISTER_MORPH_TOOLS_SPAWN_ENABLED` 30 31 - `mcp.servers` -> `MISTER_MORPH_MCP_SERVERS` 31 32
+2
web/vitepress/docs/zh/guide/prompt-architecture.md
··· 55 55 56 56 准备好最终 system prompt 之后,主 Agent 还会在请求里编排消息栈。 57 57 58 + 运行时 metadata 会作为一个 user 角色的 JSON envelope 注入,外层 key 是 `mister_morph_meta`。常见字段包括已有的 trigger / correlation 信息、运行时钟字段 `now_utc` / `now_local` / `now_local_weekday`,以及 `host_os` 这类宿主环境事实。 59 + 58 60 顺序可以理解成: 59 61 60 62 ```text