Files
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

181 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tools
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
core "agent-system-core"
)
// execHTTPTool 见 HTTPExecutor:声明式 HTTP 工具在注册时绑定 spec 闭包执行。
func HTTPExecutor(spec core.HTTPToolSpec, settingsLookup func(key string) string) Executor {
return func(rc ToolExecCtx, args json.RawMessage) (string, error) {
params := map[string]any{}
if len(args) > 0 {
if err := json.Unmarshal(args, &params); err != nil {
return "", fmt.Errorf("http tool: bad args json: %w", err)
}
}
method := strings.ToUpper(spec.Method)
if method == "" {
method = http.MethodGet
}
urlStr := expandTemplate(spec.URL, params)
var body io.Reader
if spec.BodyTemplate != "" && (method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch) {
body = bytes.NewReader([]byte(expandTemplate(spec.BodyTemplate, params)))
}
ctx := execParentCtx(rc)
req, err := http.NewRequestWithContext(ctx, method, urlStr, body)
if err != nil {
return "", err
}
for k, v := range spec.Headers {
req.Header.Set(k, expandTemplate(v, params))
}
if body != nil && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
if spec.AuthRef != "" {
token := authValue(spec.AuthRef, settingsLookup)
if token != "" {
if strings.HasPrefix(token, "Bearer ") || strings.Contains(token, " ") {
req.Header.Set("Authorization", token)
} else {
req.Header.Set("Authorization", "Bearer "+token)
}
}
}
timeout := time.Duration(spec.TimeoutMs) * time.Millisecond
if timeout <= 0 {
timeout = 15 * time.Second
}
client := &http.Client{Timeout: timeout}
if err := checkToolExternalEffect(rc); err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("http tool: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", fmt.Errorf("http tool: read body: %w", err)
}
if resp.StatusCode >= 400 {
return "", fmt.Errorf("http tool: status %d: %s", resp.StatusCode, truncateStr(string(raw), 400))
}
// JSON 则裁剪路径;否则原文返回。
var parsed any
if err := json.Unmarshal(raw, &parsed); err == nil {
if spec.ResultPath != "" {
if v, ok := pickPath(parsed, spec.ResultPath); ok {
return prettyJSON(v), nil
}
return "", fmt.Errorf("http tool: result path %q not found", spec.ResultPath)
}
return prettyJSON(parsed), nil
}
return truncateStr(string(raw), 20000), nil
}
}
// expandTemplate 替换 {{param.x.y}};未知变量替换为空串(防注入:无脚本语义)。
func expandTemplate(tpl string, params map[string]any) string {
out := tpl
for {
start := strings.Index(out, "{{")
if start < 0 {
break
}
end := strings.Index(out[start:], "}}")
if end < 0 {
break
}
end += start
key := strings.TrimSpace(out[start+2 : end])
key = strings.TrimPrefix(key, "param.") // {{param.x}} → 顶层参数 x
val := ""
if v, ok := lookup(params, key); ok {
val = fmt.Sprint(v)
}
out = out[:start] + val + out[end+2:]
}
return out
}
func lookup(params map[string]any, dotted string) (any, bool) {
parts := strings.Split(dotted, ".")
var cur any = params
for _, p := range parts {
m, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = m[p]
if !ok {
return nil, false
}
}
return cur, true
}
func pickPath(v any, dotted string) (any, bool) {
parts := strings.Split(dotted, ".")
cur := v
for _, p := range parts {
// 数组支持 "0" 数字索引
if arr, ok := cur.([]any); ok {
idx := -1
fmt.Sscanf(p, "%d", &idx)
if idx < 0 || idx >= len(arr) {
return nil, false
}
cur = arr[idx]
continue
}
m, ok := cur.(map[string]any)
if !ok {
return nil, false
}
cur, ok = m[p]
if !ok {
return nil, false
}
}
return cur, true
}
func prettyJSON(v any) string {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return fmt.Sprint(v)
}
return string(b)
}
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…(截断)"
}
// authValue 解析 AuthRef:env:NAME 或 settings 点路径。
func authValue(ref string, settingsLookup func(key string) string) string {
if strings.HasPrefix(ref, "env:") {
return os.Getenv(strings.TrimPrefix(ref, "env:"))
}
if settingsLookup != nil {
return settingsLookup(ref)
}
return ""
}