mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
The hook reads the parameters of a custom tool call, not the URL or the command they render, and not a target written in the template itself. The custom command and http tools now run the target gate inside the tool, on what they are about to run, before anything is sent or started. Gate off (the default) is unchanged: with no scope or the flag off nothing is parsed, resolved, read or written. - intercept.GateRenderedURL: the target is read as net/http dials it (host of the parsed URL, explicit port, else the scheme default), then goes through the same decision as at the hook (asset block rules, exemptions, resolution, pin lookup) and the same request-and-wait, at most three passes. A URL whose target cannot be read is blocked with TARGET_GATE_UNAVAILABLE and logged as a decided row; no request, no lookup. - intercept.GateRenderedCommand: the rendered command is read with the command-string rules of Bash, and so is the rendering without the shell quoting of the parameters (a quoted parameter inside a host name hides the name from the rules). A command that names no target passes, as in Bash. - A gated scope without the interceptor that owns the gate blocks (TARGET_GATE_UNAVAILABLE), it never lets the call through. - runCommandTool and runHTTPTool take the tool key, which names the request rows. The gate runs before the tool's own timeout starts. - guard.TargetBlockMessage (was targetBlockMessage) is exported so the tools return the same model-facing text as the hook. - Unchanged: the script tool (its target is in stored code; advisory, D9), the custom-tool test endpoint (no scope: the operator's own action, D14), the hook order (a rule or judge deny blocks first; the tool then never runs, so its rendered target is never read), the evaluation of asset rules. Tests: TestRendered* in intercept (no database); in server, against PostgreSQL: TestCustomHTTPRenderedTargetGated, TestCustomCommandRenderedTargetGated, TestCustomToolTestEndpointUngated, TestTargetGateEndToEnd (a scripted worker's WebFetch waits, a click pins, the next call passes, revoke, it asks again), TestDenyRuleWinsBeforeRenderedTarget and TestGateOffTaskIsUnchanged. Not in this change: redirect targets of WebFetch and of the custom http tool (1.9).
545 lines
18 KiB
Go
545 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/guard"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
"github.com/Autumn-27/norma/permission"
|
|
actool "github.com/Autumn-27/norma/tool"
|
|
)
|
|
|
|
// This file implements the custom tool executor (docs/custom-tool-design.md). tools rows
|
|
// with system=false are dispatched by kind: command (render the command → reuse the Bash
|
|
// low-level run), script (Python only; write a temp file, pass parameters via stdin JSON
|
|
// + env TOOL_*, use the configured interpreter), http (native request + optional proxy). Like traffic/orchestration tools they need no seeding (they already live in the tools table); they are injected via hostTools and filtered by binding.
|
|
|
|
// ---------- custom tool CRUD ----------
|
|
|
|
type customToolReq struct {
|
|
Key string `json:"key"`
|
|
Description string `json:"description"`
|
|
Schema json.RawMessage `json:"schema"`
|
|
Agents []string `json:"agents"`
|
|
Enabled bool `json:"enabled"`
|
|
Kind string `json:"kind"` // command | script | http
|
|
Exec json.RawMessage `json:"exec"`
|
|
Deferred bool `json:"deferred"`
|
|
}
|
|
|
|
var reToolKey = reAgentKey // same rule as agent key: starts with a lowercase letter, lowercase letters/digits/underscores only
|
|
|
|
func (s *Server) pgCreateCustomTool(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var req customToolReq
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
req.Key = strings.TrimSpace(req.Key)
|
|
if !reToolKey.MatchString(req.Key) {
|
|
writeErr(w, 400, "key must start with a lowercase letter and contain only lowercase letters/digits/underscores")
|
|
return
|
|
}
|
|
if req.Kind != "command" && req.Kind != "script" && req.Kind != "http" && req.Kind != "shell" {
|
|
writeErr(w, 400, "kind must be command / script / http / shell")
|
|
return
|
|
}
|
|
if req.Kind == "http" && !hasSchemaProps(req.Schema) {
|
|
writeErr(w, 400, "http tools must provide a parameter JSON Schema (cannot be left empty)")
|
|
return
|
|
}
|
|
if exist, _ := pg.GetTool(req.Key); exist != nil {
|
|
writeErr(w, 409, "this key already exists (built-in or custom tool)")
|
|
return
|
|
}
|
|
if err := pg.CreateCustomTool(&db.Tool{
|
|
Key: req.Key, Description: req.Description, Schema: req.Schema, Agents: req.Agents,
|
|
Enabled: req.Enabled, Kind: req.Kind, Exec: req.Exec, Deferred: req.Deferred,
|
|
}); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"key": req.Key})
|
|
}
|
|
|
|
func (s *Server) pgUpdateCustomTool(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
key := r.PathValue("key")
|
|
existing, err := pg.GetTool(key)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if existing == nil || existing.System {
|
|
writeErr(w, 400, "only custom tools can be edited")
|
|
return
|
|
}
|
|
var req customToolReq
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
if req.Kind != "command" && req.Kind != "script" && req.Kind != "http" && req.Kind != "shell" {
|
|
writeErr(w, 400, "kind must be command / script / http / shell")
|
|
return
|
|
}
|
|
if req.Kind == "http" && !hasSchemaProps(req.Schema) {
|
|
writeErr(w, 400, "http tools must provide a parameter JSON Schema (cannot be left empty)")
|
|
return
|
|
}
|
|
if err := pg.UpdateCustomTool(&db.Tool{
|
|
Key: key, Description: req.Description, Schema: req.Schema, Agents: req.Agents,
|
|
Enabled: req.Enabled, Kind: req.Kind, Exec: req.Exec, Deferred: req.Deferred,
|
|
}); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
func (s *Server) pgDeleteCustomTool(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
key := r.PathValue("key")
|
|
if err := pg.DeleteCustomTool(key); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"deleted": key})
|
|
}
|
|
|
|
// testToolReq is a dry-run request from the editor: run the given (possibly unsaved)
|
|
// exec spec with sample params, without persisting the tool. Same executor path as a
|
|
// real tool call — it runs arbitrary command/script/http on the server, which the
|
|
// custom-tool feature already allows, so no new capability is granted.
|
|
type testToolReq struct {
|
|
Kind string `json:"kind"` // command | script | http
|
|
Exec json.RawMessage `json:"exec"`
|
|
Params map[string]any `json:"params"`
|
|
}
|
|
|
|
// pgTestCustomTool executes an exec spec once and returns its raw output + error
|
|
// flag, so the editor can debug a tool before saving it. Per-kind timeouts still
|
|
// apply from the exec spec (with defaults); the outer ceiling is a hard backstop.
|
|
func (s *Server) pgTestCustomTool(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var req testToolReq
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeErr(w, 400, "invalid request body")
|
|
return
|
|
}
|
|
params := req.Params
|
|
if params == nil {
|
|
params = map[string]any{}
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
|
|
defer cancel()
|
|
tc := &actool.ToolContext{WorkingDir: s.m.dir} // run in the project dir, like a real call
|
|
var res actool.Result
|
|
switch req.Kind {
|
|
case "command":
|
|
res, _ = s.runCommandTool(ctx, "test", req.Exec, params, tc)
|
|
case "script":
|
|
res, _ = s.runScriptTool(ctx, "test", req.Exec, params, tc)
|
|
case "http":
|
|
res, _ = s.runHTTPTool(ctx, "test", req.Exec, params, tc)
|
|
case "shell":
|
|
writeErr(w, 400, "shell-kind tools are bash environment declarations with no executable content")
|
|
return
|
|
default:
|
|
writeErr(w, 400, "unknown tool kind: "+req.Kind)
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"output": res.Flatten(), "is_error": res.IsError})
|
|
}
|
|
|
|
// ---------- Python interpreter (detect + persist + override) ----------
|
|
|
|
const settingPythonInterp = "python_interpreter"
|
|
|
|
// detectPython finds a python interpreter absolute path (python3 preferred).
|
|
func detectPython() string {
|
|
for _, c := range []string{"python3", "python"} {
|
|
if p, err := exec.LookPath(c); err == nil {
|
|
return p
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pythonInterpreter resolves the interpreter: user-set > stored auto-detect > live
|
|
// detect. "" only when truly none found.
|
|
func (s *Server) pythonInterpreter() string {
|
|
if v, ok, _ := s.m.pg.GetSetting(settingPythonInterp); ok && strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
return detectPython()
|
|
}
|
|
|
|
// seedPythonInterpreter stores the auto-detected interpreter on startup if unset
|
|
// (never clobbers a user-set value).
|
|
func (s *Server) seedPythonInterpreter() {
|
|
if v, ok, _ := s.m.pg.GetSetting(settingPythonInterp); ok && strings.TrimSpace(v) != "" {
|
|
return
|
|
}
|
|
if p := detectPython(); p != "" {
|
|
_ = s.m.pg.SetSetting(settingPythonInterp, p)
|
|
log.Printf("[custom-tool] auto-detected python interpreter: %s", p)
|
|
}
|
|
}
|
|
|
|
// ---------- exec spec ----------
|
|
|
|
type commandExec struct {
|
|
Command string `json:"command"`
|
|
TimeoutMs int `json:"timeout_ms"`
|
|
}
|
|
type scriptExec struct {
|
|
Code string `json:"code"`
|
|
TimeoutMs int `json:"timeout_ms"`
|
|
}
|
|
type httpExec struct {
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Headers map[string]string `json:"headers"`
|
|
Body string `json:"body"`
|
|
TimeoutMs int `json:"timeout_ms"`
|
|
Proxy string `json:"proxy"`
|
|
UseRecordingProxy bool `json:"use_recording_proxy"`
|
|
}
|
|
|
|
func timeoutOr(ms, def int) time.Duration {
|
|
if ms <= 0 {
|
|
return time.Duration(def) * time.Millisecond
|
|
}
|
|
return time.Duration(ms) * time.Millisecond
|
|
}
|
|
|
|
// ---------- common tool construction ----------
|
|
|
|
// customTools builds CoreTools for every user-defined (system=false) tool row.
|
|
// shell-kind tools are environment hints only — they surface in the Bash tool
|
|
// description via ToolResolve and do NOT create callable tool entries here.
|
|
func (s *Server) customTools() ([]actool.CoreTool, error) {
|
|
rows, err := s.m.pg.ListCustomTools()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]actool.CoreTool, 0, len(rows))
|
|
for _, t := range rows {
|
|
if t.Kind == "shell" {
|
|
continue // shell hints are handled by ToolResolve → Bash description
|
|
}
|
|
out = append(out, s.buildCustomTool(t))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// buildCustomTool turns one custom-tool row into a CoreTool. Empty schema → a thin
|
|
// {args:string} (thin-wrapper tool), so command/http templates can use {args}.
|
|
func (s *Server) buildCustomTool(t *db.Tool) actool.CoreTool {
|
|
schema := ensureSchema(t.Schema)
|
|
key, kind, execRaw := t.Key, t.Kind, t.Exec
|
|
run := func(ctx context.Context, in json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
|
|
var params map[string]any
|
|
if len(in) > 0 {
|
|
_ = json.Unmarshal(in, ¶ms)
|
|
}
|
|
if params == nil {
|
|
params = map[string]any{}
|
|
}
|
|
switch kind {
|
|
case "command":
|
|
return s.runCommandTool(ctx, key, execRaw, params, tc)
|
|
case "script":
|
|
return s.runScriptTool(ctx, key, execRaw, params, tc)
|
|
case "http":
|
|
return s.runHTTPTool(ctx, key, execRaw, params, tc)
|
|
default:
|
|
return actool.Errorf("unknown custom tool kind: " + kind), nil
|
|
}
|
|
}
|
|
return actool.Build(actool.Spec{
|
|
Name: key, Description: t.Description, Schema: schema,
|
|
Permissions: func(context.Context, json.RawMessage, permission.Context) permission.Decision {
|
|
return permission.Allowed()
|
|
},
|
|
Run: run,
|
|
})
|
|
}
|
|
|
|
// hasSchemaProps reports whether raw is a JSON-Schema object with ≥1 property.
|
|
// http tools require an explicit schema (the auto {args} shell can't name the
|
|
// {param} placeholders in URL/headers/body), so an empty schema is rejected.
|
|
func hasSchemaProps(raw json.RawMessage) bool {
|
|
if len(raw) == 0 {
|
|
return false
|
|
}
|
|
var m map[string]any
|
|
if json.Unmarshal(raw, &m) != nil {
|
|
return false
|
|
}
|
|
props, _ := m["properties"].(map[string]any)
|
|
return len(props) > 0
|
|
}
|
|
|
|
// ensureSchema returns the tool's schema, or a thin {args:string} when none given.
|
|
func ensureSchema(raw json.RawMessage) map[string]any {
|
|
var m map[string]any
|
|
if len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &m)
|
|
}
|
|
props, _ := m["properties"].(map[string]any)
|
|
if len(props) > 0 {
|
|
return m
|
|
}
|
|
return map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"args": map[string]any{"type": "string", "description": "command/parameters (free text)"},
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------- command: render the command → reuse the Bash low-level run ----------
|
|
|
|
func (s *Server) runCommandTool(ctx context.Context, key string, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
|
|
var spec commandExec
|
|
_ = json.Unmarshal(execRaw, &spec)
|
|
if strings.TrimSpace(spec.Command) == "" {
|
|
return actool.Errorf("command is empty"), nil
|
|
}
|
|
cmd := renderTemplate(spec.Command, params, shellQuote)
|
|
// The hook saw the parameters, not the command they render: the target gate
|
|
// reads the rendered command here, also without the quoting of the parameters.
|
|
if res := intercept.GateRenderedCommand(ctx, key, cmd, renderTemplate(spec.Command, params, identity)); res.Code != "" {
|
|
return actool.Errorf(guard.TargetBlockMessage(res)), nil
|
|
}
|
|
// Reuse the low-level run Bash also uses (via the Bash CoreTool.Call): it
|
|
// automatically inherits the safety floor/timeout/proxy env/output overflow. The tool is a peer of Bash sharing that low level, not routed through the Bash tool for the model to call.
|
|
bashIn, _ := json.Marshal(map[string]any{"command": cmd})
|
|
if spec.TimeoutMs > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, timeoutOr(spec.TimeoutMs, 120000))
|
|
defer cancel()
|
|
}
|
|
return actool.NewBash().Call(ctx, bashIn, tc)
|
|
}
|
|
|
|
// ---------- script (Python only): temp file + stdin JSON + env ----------
|
|
|
|
func (s *Server) runScriptTool(ctx context.Context, key string, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
|
|
var spec scriptExec
|
|
_ = json.Unmarshal(execRaw, &spec)
|
|
if strings.TrimSpace(spec.Code) == "" {
|
|
return actool.Errorf("script code is empty"), nil
|
|
}
|
|
interp := s.pythonInterpreter()
|
|
if interp == "" {
|
|
return actool.Errorf("no python interpreter configured or detected (set it in system settings)"), nil
|
|
}
|
|
workDir := s.m.dir
|
|
var sessionEnv []string
|
|
if tc != nil {
|
|
if tc.WorkingDir != "" {
|
|
workDir = tc.WorkingDir
|
|
}
|
|
sessionEnv = tc.Env
|
|
}
|
|
body, err := execPython(ctx, interp, key, spec.Code, params, workDir, sessionEnv, timeoutOr(spec.TimeoutMs, 120000))
|
|
if err != nil {
|
|
return actool.Errorf(err.Error()), nil
|
|
}
|
|
return actool.Text(actool.Capture(tc, body)), nil
|
|
}
|
|
|
|
// execPython writes the code to a temp .py under workDir/.tools, runs it via interp
|
|
// with the params JSON on stdin + scalar params mirrored to TOOL_<NAME> env, and
|
|
// returns combined stdout+stderr (with a timeout/exit note). Standalone + testable.
|
|
func execPython(ctx context.Context, interp, key, code string, params map[string]any, workDir string, sessionEnv []string, timeout time.Duration) (string, error) {
|
|
toolsDir := filepath.Join(workDir, ".tools")
|
|
if err := os.MkdirAll(toolsDir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
f, err := os.CreateTemp(toolsDir, key+"-*.py")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
tmp := f.Name()
|
|
defer os.Remove(tmp)
|
|
if _, err := f.WriteString(code); err != nil {
|
|
f.Close()
|
|
return "", err
|
|
}
|
|
f.Close()
|
|
|
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
c := exec.CommandContext(runCtx, interp, tmp)
|
|
c.Dir = workDir
|
|
c.Env = append(os.Environ(), sessionEnv...) // session proxy env
|
|
for k, v := range params { // scalar params mirrored as TOOL_<NAME>
|
|
if sv, ok := scalarStr(v); ok {
|
|
c.Env = append(c.Env, "TOOL_"+strings.ToUpper(k)+"="+sv)
|
|
}
|
|
}
|
|
pj, _ := json.Marshal(params)
|
|
c.Stdin = bytes.NewReader(pj) // parameter JSON goes via stdin
|
|
out, err := c.CombinedOutput()
|
|
body := string(out)
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
body += "\n... [terminated on timeout] ..."
|
|
} else if err != nil {
|
|
body += "\n[exit: " + err.Error() + "]"
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// ---------- http: native request + proxy ----------
|
|
|
|
func (s *Server) runHTTPTool(ctx context.Context, key string, execRaw json.RawMessage, params map[string]any, tc *actool.ToolContext) (actool.Result, error) {
|
|
var spec httpExec
|
|
_ = json.Unmarshal(execRaw, &spec)
|
|
method := strings.ToUpper(strings.TrimSpace(spec.Method))
|
|
if method == "" {
|
|
method = "GET"
|
|
}
|
|
rawURL := renderTemplate(spec.URL, params, identity)
|
|
if strings.TrimSpace(rawURL) == "" {
|
|
return actool.Errorf("http url is empty"), nil
|
|
}
|
|
// The hook saw the parameters, not the URL they render (nor a URL written in
|
|
// the template): the target gate reads the rendered URL here, before the
|
|
// tool's own timeout starts, so a wait for an approval does not consume it.
|
|
if res := intercept.GateRenderedURL(ctx, key, rawURL); res.Code != "" {
|
|
return actool.Errorf(guard.TargetBlockMessage(res)), nil
|
|
}
|
|
var bodyReader io.Reader
|
|
if spec.Body != "" {
|
|
bodyReader = strings.NewReader(renderTemplate(spec.Body, params, identity))
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, timeoutOr(spec.TimeoutMs, 30000))
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(runCtx, method, rawURL, bodyReader)
|
|
if err != nil {
|
|
return actool.Errorf(err.Error()), nil
|
|
}
|
|
for k, v := range spec.Headers {
|
|
req.Header.Set(k, renderTemplate(v, params, identity))
|
|
}
|
|
client := &http.Client{Timeout: timeoutOr(spec.TimeoutMs, 30000)}
|
|
if tr := s.httpProxyTransport(spec); tr != nil {
|
|
client.Transport = tr
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return actool.Errorf("request failed: " + err.Error()), nil
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
out := map[string]any{"status": resp.StatusCode, "body": string(respBody)}
|
|
b, _ := json.Marshal(out)
|
|
return actool.Text(actool.Capture(tc, string(b))), nil
|
|
}
|
|
|
|
// httpProxyTransport builds a Transport for the http tool's proxy config, or nil
|
|
// (direct). use_recording_proxy routes through the recording proxy + trusts its CA.
|
|
func (s *Server) httpProxyTransport(spec httpExec) *http.Transport {
|
|
proxyStr := strings.TrimSpace(spec.Proxy)
|
|
var caFile string
|
|
if spec.UseRecordingProxy {
|
|
if addr := s.m.ProxyAddr(); addr != "" {
|
|
proxyStr = "http://" + addr
|
|
caFile = s.m.ProxyCACert()
|
|
}
|
|
}
|
|
if proxyStr == "" {
|
|
return nil
|
|
}
|
|
pu, err := url.Parse(proxyStr)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
tr := &http.Transport{Proxy: http.ProxyURL(pu)}
|
|
if caFile != "" {
|
|
if pem, err := os.ReadFile(caFile); err == nil {
|
|
pool := x509.NewCertPool()
|
|
if pool.AppendCertsFromPEM(pem) {
|
|
tr.TLSClientConfig = &tls.Config{RootCAs: pool}
|
|
}
|
|
}
|
|
}
|
|
return tr
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
|
|
func identity(s string) string { return s }
|
|
|
|
// shellQuote single-quotes a value for safe shell interpolation.
|
|
func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
|
|
|
|
// renderTemplate replaces {name} placeholders with each param's rendered value.
|
|
func renderTemplate(tmpl string, params map[string]any, quote func(string) string) string {
|
|
out := tmpl
|
|
for k, v := range params {
|
|
out = strings.ReplaceAll(out, "{"+k+"}", quote(valToStr(v)))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// valToStr renders a param value: scalars as-is, arrays/objects as compact JSON.
|
|
func valToStr(v any) string {
|
|
if sv, ok := scalarStr(v); ok {
|
|
return sv
|
|
}
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
}
|
|
|
|
// scalarStr returns (string, true) for scalar values, ("", false) for arrays/objects.
|
|
func scalarStr(v any) (string, bool) {
|
|
switch x := v.(type) {
|
|
case string:
|
|
return x, true
|
|
case bool:
|
|
return fmt.Sprintf("%t", x), true
|
|
case float64:
|
|
if x == float64(int64(x)) {
|
|
return fmt.Sprintf("%d", int64(x)), true
|
|
}
|
|
return fmt.Sprintf("%g", x), true
|
|
case nil:
|
|
return "", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|