be08938f1f
Finding 6 of REVIEW-2026-07-30.md. runWithTimeout could not cancel anything, because Provider.Execute took no context: the goroutine ran on to the HTTP client's 60s timeout, outliving the 30s capability timeout. Provider.Execute now takes a context carrying that timeout, and the workspace provider issues its tool call with http.NewRequestWithContext, so a timed-out execution actually tears the request down. A read-only capability whose provider call times out now resolves to failed rather than unknown. Spec §4.3 reserves unknown for executions whose side effect may or may not have landed, and never retries them — which made read failures both unretryable and indistinguishable from genuinely ambiguous mutations, for calls that by definition have no side effect. Mutating capabilities still resolve to unknown. 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
|
|
}
|