mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
WebFetch and the custom http tool followed redirects with net/http's default policy, so a pinned host could send the client to a target nobody approved. RedirectCheck is now the CheckRedirect policy of both clients. - Gate off, or no scope (operator test endpoint): only the 10-hop limit, same error text; nothing is parsed, resolved, read or written. - Gate on: each hop runs through the call's decision code (URL rules on the hop URL, block rules before any lookup, exemptions, resolution, pins). A hop no pin covers is not followed and never waits: its request row is found or created and the call fails with TARGET_APPROVAL_PENDING. http -> https asks again for port 443 unless a (host, 0) pin exists. An unreadable hop is blocked with TARGET_GATE_UNAVAILABLE. - Rows raised by a hop record redirect_from in the audit targets. - Norma v0.4.7 has WebFetchConfig.CheckRedirect but no agentcore option, so the four agent kinds build WebFetch themselves (same proxy, CA and position in the tool list) instead of EnableWebFetch. Norma carries the policy to the direct retry client.
547 lines
18 KiB
Go
547 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)}
|
|
// Every redirect hop goes through the target gate, as the rendered URL did.
|
|
client.CheckRedirect = intercept.RedirectCheck(key)
|
|
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
|
|
}
|
|
}
|