3ac0fbb6d7
The review flagged three files as unformatted at HEAD. systemd.go was deleted and handler.go/client.go were reformatted as a side effect of being edited; this is the remaining realignment. No behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uea55zaiWuEByEDC4UBSdd
272 lines
7.3 KiB
Go
272 lines
7.3 KiB
Go
package provider
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kami/hexis/internal/domain"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func LoadToolAllowlist(path string) (ToolAllowlist, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ToolAllowlist{}, fmt.Errorf("read allowlist: %w", err)
|
|
}
|
|
var allowlist ToolAllowlist
|
|
if err := yaml.Unmarshal(data, &allowlist); err != nil {
|
|
return ToolAllowlist{}, fmt.Errorf("parse allowlist: %w", err)
|
|
}
|
|
if allowlist.Tools == nil {
|
|
allowlist.Tools = map[string]ToolMapping{}
|
|
}
|
|
return allowlist, nil
|
|
}
|
|
|
|
type WorkspaceMCPProvider struct {
|
|
mu sync.RWMutex
|
|
baseURL string
|
|
httpClient *http.Client
|
|
tools []WorkspaceTool
|
|
allowlist ToolAllowlist
|
|
}
|
|
|
|
type WorkspaceTool struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema any `json:"inputSchema"`
|
|
}
|
|
|
|
type ToolAllowlist struct {
|
|
Tools map[string]ToolMapping `yaml:"tools" json:"tools"`
|
|
}
|
|
|
|
type ToolMapping struct {
|
|
Capability string `yaml:"capability" json:"capability"`
|
|
Risk string `yaml:"risk" json:"risk"`
|
|
TargetType string `yaml:"target_type,omitempty" json:"target_type,omitempty"`
|
|
ReadOnly bool `yaml:"read_only" json:"read_only"`
|
|
SideEffects string `yaml:"side_effects,omitempty" json:"side_effects,omitempty"`
|
|
Enabled *bool `yaml:"enabled,omitempty" json:"enabled,omitempty"`
|
|
AllowParams []string `yaml:"allow_params,omitempty" json:"allow_params,omitempty"`
|
|
}
|
|
|
|
func (r *ToolAllowlist) IsEnabled(name string) bool {
|
|
m, ok := r.Tools[name]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if m.Enabled != nil && !*m.Enabled {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (r *ToolAllowlist) Mapping(name string) (ToolMapping, bool) {
|
|
m, ok := r.Tools[name]
|
|
return m, ok
|
|
}
|
|
|
|
func NewWorkspaceMCPProvider(baseURL string, allowlist ToolAllowlist) *WorkspaceMCPProvider {
|
|
return &WorkspaceMCPProvider{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
httpClient: &http.Client{
|
|
Timeout: 60 * time.Second,
|
|
},
|
|
allowlist: allowlist,
|
|
}
|
|
}
|
|
|
|
func (p *WorkspaceMCPProvider) Name() string {
|
|
return "workspace_mcp"
|
|
}
|
|
|
|
func (p *WorkspaceMCPProvider) DiscoverTools() ([]WorkspaceTool, error) {
|
|
resp, err := p.httpClient.Get(fmt.Sprintf("%s/api/tools", p.baseURL))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("discover workspace tools: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
Tools []WorkspaceTool `json:"tools"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("decode workspace tools: %w", err)
|
|
}
|
|
|
|
p.mu.Lock()
|
|
p.tools = result.Tools
|
|
p.mu.Unlock()
|
|
|
|
return result.Tools, nil
|
|
}
|
|
|
|
func (p *WorkspaceMCPProvider) Execute(ctx context.Context, capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) {
|
|
toolName := p.capabilityToTool(capability.Name)
|
|
if toolName == "" {
|
|
return nil, fmt.Errorf("no workspace tool mapped for capability %q", capability.Name)
|
|
}
|
|
|
|
mapping, ok := p.allowlist.Mapping(toolName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("tool %q not in allowlist", toolName)
|
|
}
|
|
if !p.allowlist.IsEnabled(toolName) {
|
|
return nil, fmt.Errorf("tool %q is disabled in allowlist", toolName)
|
|
}
|
|
|
|
args := map[string]any{}
|
|
if req.Arguments != nil {
|
|
if len(mapping.AllowParams) > 0 {
|
|
for k, v := range req.Arguments {
|
|
for _, allowed := range mapping.AllowParams {
|
|
if k == allowed {
|
|
args[k] = v
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
args = req.Arguments
|
|
}
|
|
}
|
|
|
|
body, _ := json.Marshal(args)
|
|
// The caller's context carries the capability timeout. Building the
|
|
// request with it means a cancelled execution tears down the in-flight
|
|
// HTTP call instead of leaving it to run to the client's own (much
|
|
// longer) timeout.
|
|
httpReq, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
fmt.Sprintf("%s/api/tool/%s", p.baseURL, toolName),
|
|
bytes.NewReader(body),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build workspace tool request: %w", err)
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := p.httpClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("call workspace tool %q: %w", toolName, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read workspace tool response: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return map[string]any{
|
|
"error": string(respBody),
|
|
"http_status": resp.StatusCode,
|
|
}, fmt.Errorf("workspace tool %q returned %d: %s", toolName, resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result any
|
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
|
return map[string]any{
|
|
"raw": string(respBody),
|
|
}, nil
|
|
}
|
|
|
|
// workspace-mcp catches tool-handler exceptions and reports them as an
|
|
// ERROR-coded warning inside a 200 response envelope rather than an HTTP
|
|
// error status, so a plain status-code check would report every such
|
|
// failure as a successful execution. Surface it as an error here so it
|
|
// maps to Hexis's failed execution status instead.
|
|
if envelopeErr := workspaceEnvelopeError(result); envelopeErr != "" {
|
|
return map[string]any{"result": result}, fmt.Errorf("workspace tool %q reported an error: %s", toolName, envelopeErr)
|
|
}
|
|
|
|
return map[string]any{
|
|
"result": result,
|
|
}, nil
|
|
}
|
|
|
|
func workspaceEnvelopeError(result any) string {
|
|
envelope, ok := result.(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
warnings, ok := envelope["warnings"].([]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
for _, w := range warnings {
|
|
warning, ok := w.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if code, _ := warning["code"].(string); code == "ERROR" {
|
|
message, _ := warning["message"].(string)
|
|
return message
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (p *WorkspaceMCPProvider) capabilityToTool(capName string) string {
|
|
for toolName, mapping := range p.allowlist.Tools {
|
|
if mapping.Capability == capName {
|
|
return toolName
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (p *WorkspaceMCPProvider) BuildCapabilities() []domain.Capability {
|
|
var caps []domain.Capability
|
|
for toolName, mapping := range p.allowlist.Tools {
|
|
if !p.allowlist.IsEnabled(toolName) {
|
|
continue
|
|
}
|
|
readOnly := mapping.ReadOnly
|
|
if mapping.Risk == "read" {
|
|
readOnly = true
|
|
}
|
|
now := time.Now().UTC()
|
|
|
|
caps = append(caps, domain.Capability{
|
|
ID: fmt.Sprintf("cap_ws_%s", strings.ReplaceAll(mapping.Capability, ".", "_")),
|
|
Name: mapping.Capability,
|
|
Description: fmt.Sprintf("Workspace tool: %s", toolName),
|
|
TargetTypes: ifString(mapping.TargetType != "", []string{mapping.TargetType}, nil),
|
|
TargetEntityID: "",
|
|
Provider: "workspace_mcp",
|
|
Operation: toolName,
|
|
Risk: mapping.Risk,
|
|
ReadOnly: readOnly,
|
|
ExpectedSideEffects: mapping.SideEffects,
|
|
// Derived server-side from the risk tier, exactly as the HTTP
|
|
// registration path does (ECOSYSTEM-SPEC.md §4.3). Leaving these
|
|
// zero-valued registered every workspace capability disabled.
|
|
Enabled: domain.EnabledForRisk(mapping.Risk),
|
|
RequiresConfirmation: domain.RequiresConfirmationForRisk(mapping.Risk),
|
|
TimeoutSeconds: domain.DefaultCapabilityTimeoutSeconds,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
Version: 1,
|
|
})
|
|
}
|
|
return caps
|
|
}
|
|
|
|
func ifString(cond bool, a, b []string) []string {
|
|
if cond {
|
|
return a
|
|
}
|
|
return b
|
|
}
|