Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions agent/internal/capability/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package capability

import (
"encoding/json"
"errors"
"os"
"path/filepath"
)

const configFileName = "local-capability.json"

type localConfig struct {
FixtureEndpoint string `json:"fixtureEndpoint"`
}

// SaveFixtureEndpoint stores the operator-selected loopback fixture origin in
// a private Runtime-local config file. It deliberately has no registry shape.
func SaveFixtureEndpoint(runtimeDir, endpoint string) error {
if _, err := validateFixtureEndpoint(endpoint); err != nil {
return ErrUnavailable
}
data, err := json.Marshal(localConfig{FixtureEndpoint: endpoint})
if err != nil {
return errors.New("local capability configuration failed")
}
if err := os.MkdirAll(runtimeDir, 0o700); err != nil {
return errors.New("local capability configuration failed")
}
path := filepath.Join(runtimeDir, configFileName)
tmp, err := os.CreateTemp(runtimeDir, ".local-capability-*")
if err != nil {
return errors.New("local capability configuration failed")
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0o600); err != nil {
_ = tmp.Close()
return errors.New("local capability configuration failed")
}
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
return errors.New("local capability configuration failed")
}
if err := tmp.Close(); err != nil {
return errors.New("local capability configuration failed")
}
if err := os.Rename(tmpName, path); err != nil {
return errors.New("local capability configuration failed")
}
return nil
}

// LoadFixtureAdapter creates the configured adapter without returning its
// endpoint or any underlying parser/network error to callers.
func LoadFixtureAdapter(runtimeDir string) (*FixtureAdapter, error) {
data, err := os.ReadFile(filepath.Join(runtimeDir, configFileName))
if err != nil || len(data) > 512 {
return nil, ErrUnavailable
}
var config localConfig
if json.Unmarshal(data, &config) != nil {
return nil, ErrUnavailable
}
return NewFixtureAdapter(config.FixtureEndpoint)
}
168 changes: 168 additions & 0 deletions agent/internal/capability/controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Package capability owns bounded, Runtime-local semantic capability calls.
package capability

import (
"context"
"encoding/json"
"errors"
"regexp"
"strings"
"time"
)

const (
CapabilityID = "local_fixture"
MaxIDBytes = 48
MaxActionBytes = 32
MaxArgsBytes = 1024
MaxResultBytes = 4096
MaxDescriptor = 2048
RequestTimeout = 1500 * time.Millisecond
)

var (
ErrUnknownCapability = errors.New("unknown capability")
ErrUnsupportedAction = errors.New("unsupported action")
ErrInvalidArgs = errors.New("invalid capability arguments")
ErrUnavailable = errors.New("local capability unavailable")
ErrTimeout = errors.New("local capability timed out")
ErrTooLarge = errors.New("local capability payload exceeds limit")
ErrMalformedResponse = errors.New("local capability response malformed")
)

var unsafeDescriptorPattern = regexp.MustCompile(`(?i)(https?://|"?(endpoint|credential|password|secret|token|authorization|cookie|adapter|protocol|hostname)"?\s*[:=])`)

// Descriptor contains semantic metadata only. Adapter configuration is never
// represented here.
type Descriptor struct {
ID string `json:"id"`
Title string `json:"title"`
Version string `json:"version"`
Observe string `json:"observe"`
Actions []ActionSchema `json:"actions"`
}

type ActionSchema struct {
Name string `json:"name"`
Title string `json:"title"`
Args string `json:"args"`
Properties map[string]string `json:"properties"`
Required []string `json:"required,omitempty"`
}

type Observation struct {
CapabilityID string `json:"capabilityId"`
State json.RawMessage `json:"state"`
}

// Adapter is intentionally semantic: it has no arbitrary URL, method, or path
// arguments. Implementations own their local endpoint configuration.
type Adapter interface {
Describe() Descriptor
Observe(context.Context) (json.RawMessage, error)
Invoke(context.Context, string, json.RawMessage) (json.RawMessage, error)
}

type Controller struct {
adapter Adapter
timeout time.Duration
}

func NewController(adapter Adapter) *Controller {
return &Controller{adapter: adapter, timeout: RequestTimeout}
}

func (c *Controller) Describe(id string) (Descriptor, error) {
if c == nil || c.adapter == nil {
return Descriptor{}, ErrUnavailable
}
d := c.adapter.Describe()
b, err := json.Marshal(d)
if id != d.ID {
return Descriptor{}, ErrUnknownCapability
}
if err != nil || len(b) > MaxDescriptor || len(d.ID) > MaxIDBytes || unsafeDescriptorPattern.Match(b) {
return Descriptor{}, ErrMalformedResponse
}
return d, nil
}

// DescribeAll returns the bounded semantic descriptors currently owned by
// this controller. An unconfigured controller has no discoverable entries.
func (c *Controller) DescribeAll() []Descriptor {
if c == nil || c.adapter == nil {
return []Descriptor{}
}
descriptor := c.adapter.Describe()
validated, err := c.Describe(descriptor.ID)
if err != nil {
return []Descriptor{}
}
return []Descriptor{validated}
}

func (c *Controller) Observe(ctx context.Context, id string) (Observation, error) {
if c == nil || c.adapter == nil {
return Observation{}, ErrUnavailable
}
if id != c.adapter.Describe().ID {
return Observation{}, ErrUnknownCapability
}
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
result, err := c.adapter.Observe(ctx)
if err != nil {
return Observation{}, safeError(ctx, err)
}
if len(result) > MaxResultBytes {
return Observation{}, ErrTooLarge
}
if len(result) == 0 || !json.Valid(result) {
return Observation{}, ErrMalformedResponse
}
return Observation{CapabilityID: id, State: append(json.RawMessage(nil), result...)}, nil
}

func (c *Controller) Invoke(ctx context.Context, id, action string, args json.RawMessage) (json.RawMessage, error) {
if c == nil || c.adapter == nil {
return nil, ErrUnavailable
}
if id != c.adapter.Describe().ID {
return nil, ErrUnknownCapability
}
if len(action) == 0 || len(action) > MaxActionBytes || strings.TrimSpace(action) != action {
return nil, ErrUnsupportedAction
}
if len(args) > MaxArgsBytes {
return nil, ErrTooLarge
}
if len(args) == 0 || !json.Valid(args) {
return nil, ErrInvalidArgs
}
ctx, cancel := context.WithTimeout(ctx, c.timeout)
defer cancel()
result, err := c.adapter.Invoke(ctx, action, append(json.RawMessage(nil), args...))
if err != nil {
return nil, safeError(ctx, err)
}
if len(result) > MaxResultBytes {
return nil, ErrTooLarge
}
if len(result) == 0 || !json.Valid(result) {
return nil, ErrMalformedResponse
}
return append(json.RawMessage(nil), result...), nil
}

func safeError(ctx context.Context, err error) error {
if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
return ErrTimeout
}
if errors.Is(ctx.Err(), context.Canceled) {
return ErrUnavailable
}
if errors.Is(err, ErrUnsupportedAction) || errors.Is(err, ErrInvalidArgs) || errors.Is(err, ErrUnavailable) || errors.Is(err, ErrTooLarge) || errors.Is(err, ErrMalformedResponse) {
return err
}
return ErrUnavailable
}
Loading
Loading