diff --git a/go.mod b/go.mod index 19f2126..4e65080 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.26.5 require ( github.com/spice-framework/spice v0.1.0-preview.2 - github.com/spice-framework/spice-agent v0.1.0-preview.4 + github.com/spice-framework/spice-agent v0.1.0-preview.5 golang.org/x/sys v0.47.0 ) diff --git a/go.sum b/go.sum index 7cc495e..ce799d3 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/spice-framework/spice v0.1.0-preview.2 h1:5pYgTlUUzC/xZISetG/U6c1L/I3f8dUQSZhuo6YqxiA= github.com/spice-framework/spice v0.1.0-preview.2/go.mod h1:dBZV5UZcbY6pzhfGNtvAwQIJ8YsFna+jf1SAlmukJfk= -github.com/spice-framework/spice-agent v0.1.0-preview.4 h1:DB8/zvFlpegLl3MWqwBj7JJQvbUoAnomOafRKs/NJbU= -github.com/spice-framework/spice-agent v0.1.0-preview.4/go.mod h1:pbhYOeNgn4pCIhEmcdbjnFjJijY4ZSLM8ZHxaF2dxz0= +github.com/spice-framework/spice-agent v0.1.0-preview.5 h1:rGND9DYx3pssliD1tZQOvPDOZ5GVfQLDc7VJQI3HLOM= +github.com/spice-framework/spice-agent v0.1.0-preview.5/go.mod h1:pbhYOeNgn4pCIhEmcdbjnFjJijY4ZSLM8ZHxaF2dxz0= github.com/spice-framework/toolchain v0.1.0-preview.1.0.20260806203056-d0b9ac086bd6 h1:paTYw/o/6OsbNAvOWvjicOOqWyyt2Nd3vWdoPq8+BjA= github.com/spice-framework/toolchain v0.1.0-preview.1.0.20260806203056-d0b9ac086bd6/go.mod h1:5qwAMEFRzVhJTTD96xwQXMYFlUYwBWlwNNeOhZqqPeg= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= diff --git a/vendor/github.com/spice-framework/spice-agent/interaction/doc.go b/vendor/github.com/spice-framework/spice-agent/interaction/doc.go new file mode 100644 index 0000000..ca310da --- /dev/null +++ b/vendor/github.com/spice-framework/spice-agent/interaction/doc.go @@ -0,0 +1,5 @@ +// Package interaction owns UI-neutral request, response, and broker SPIs. +// +// @import { NamedInterface } from "github.com/spice-framework/spice/annotation/modulith" +// @NamedInterface("interaction") +package interaction diff --git a/vendor/github.com/spice-framework/spice-agent/interaction/interaction.go b/vendor/github.com/spice-framework/spice-agent/interaction/interaction.go new file mode 100644 index 0000000..edf6463 --- /dev/null +++ b/vendor/github.com/spice-framework/spice-agent/interaction/interaction.go @@ -0,0 +1,156 @@ +package interaction + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const MaximumPayloadBytes = 512 << 10 + +// Scope identifies the immutable run authority that owns an interaction. +// Brokers use it to route pending requests without depending on agent internals. +type Scope struct { + runID string +} + +// NewScope constructs a validated interaction scope. +func NewScope(runID string) (Scope, error) { + if err := token("interaction run ID", runID); err != nil { + return Scope{}, err + } + return Scope{runID: runID}, nil +} + +// Validate rejects a zero or malformed scope. +func (scope Scope) Validate() error { + _, err := NewScope(scope.runID) + return err +} + +// RunID returns the stable run that owns the interaction. +func (scope Scope) RunID() string { return scope.runID } + +// ID identifies one interaction lifecycle. +type ID string + +// Request asks an injected broker for typed user input. +type Request struct { + id ID + kind string + prompt string + schema json.RawMessage +} + +// NewRequest validates and defensively copies one interaction request. +func NewRequest(id ID, kind, prompt string, schema json.RawMessage) (Request, error) { + if err := token("interaction ID", string(id)); err != nil { + return Request{}, err + } + if err := token("interaction kind", kind); err != nil { + return Request{}, err + } + if prompt == "" || prompt != strings.TrimSpace(prompt) { + return Request{}, errors.New("interaction prompt must be non-empty without surrounding whitespace") + } + if len(prompt) > MaximumPayloadBytes { + return Request{}, fmt.Errorf("interaction prompt exceeds %d bytes", MaximumPayloadBytes) + } + if err := validateJSON("interaction schema", schema); err != nil { + return Request{}, err + } + return Request{id: id, kind: kind, prompt: prompt, schema: cloneJSON(schema)}, nil +} + +func (request Request) Validate() error { + _, err := NewRequest(request.id, request.kind, request.prompt, request.schema) + return err +} + +func (request Request) ID() ID { return request.id } +func (request Request) Kind() string { return request.kind } +func (request Request) Prompt() string { return request.prompt } +func (request Request) Schema() json.RawMessage { return cloneJSON(request.schema) } +func (request Request) Clone() Request { + return Request{id: request.id, kind: request.kind, prompt: request.prompt, schema: request.Schema()} +} + +// Response completes one interaction with structured user input. +type Response struct { + id ID + value json.RawMessage +} + +// NewResponse validates and defensively copies one response. +func NewResponse(id ID, value json.RawMessage) (Response, error) { + if err := token("interaction ID", string(id)); err != nil { + return Response{}, err + } + if err := validateJSON("interaction response", value); err != nil { + return Response{}, err + } + return Response{id: id, value: cloneJSON(value)}, nil +} + +func (response Response) Validate() error { + _, err := NewResponse(response.id, response.value) + return err +} + +func (response Response) ID() ID { return response.id } +func (response Response) Value() json.RawMessage { return cloneJSON(response.value) } +func (response Response) Clone() Response { return Response{id: response.id, value: response.Value()} } + +// Broker is the UI-neutral user-interaction port injected into the engine. +// Implementations must be concurrent-safe and cooperatively honor context. +type Broker interface { + Request(context.Context, Scope, Request) (Response, error) +} + +// Requester is a run-bound interaction lifecycle capability. Unlike Broker it +// accepts no caller-supplied Scope; the owner fixes run authority at binding. +type Requester interface { + Request(context.Context, Request) (Response, error) +} + +// UnavailableRequester is a fail-closed capability for direct dispatcher tests +// and embeddings that deliberately provide no run interaction lifecycle. +type UnavailableRequester struct{} + +// Request always fails without observing request payload data. +func (UnavailableRequester) Request(context.Context, Request) (Response, error) { + return Response{}, errors.New("interaction requester is unavailable") +} + +// UnavailableBroker is the fail-closed fallback when no client owns prompts. +type UnavailableBroker struct{} + +func (UnavailableBroker) Request(context.Context, Scope, Request) (Response, error) { + return Response{}, errors.New("interaction broker is unavailable") +} + +func validateJSON(label string, value json.RawMessage) error { + if len(value) == 0 || !json.Valid(value) { + return fmt.Errorf("%s must contain valid JSON", label) + } + if len(value) > MaximumPayloadBytes { + return fmt.Errorf("%s exceeds %d bytes", label, MaximumPayloadBytes) + } + return nil +} + +func token(label, value string) error { + if value == "" || value != strings.TrimSpace(value) { + return fmt.Errorf("%s must be non-empty without surrounding whitespace", label) + } + if len(value) > 128 { + return fmt.Errorf("%s exceeds 128 bytes", label) + } + return nil +} + +func cloneJSON(value json.RawMessage) json.RawMessage { + return append(json.RawMessage(nil), value...) +} diff --git a/vendor/github.com/spice-framework/spice-agent/stage/dispatch_guard.go b/vendor/github.com/spice-framework/spice-agent/stage/dispatch_guard.go new file mode 100644 index 0000000..80ad449 --- /dev/null +++ b/vendor/github.com/spice-framework/spice-agent/stage/dispatch_guard.go @@ -0,0 +1,303 @@ +package stage + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync/atomic" + + "github.com/spice-framework/spice-agent/interaction" + "github.com/spice-framework/spice-agent/tool" +) + +const dispatchFingerprintPrefix = "sha256:" + +// ToolDispatchScope is immutable authority and execution identity supplied by +// the engine for one tool dispatch. It contains no policy decision and grants +// no capability by itself. +type ToolDispatchScope struct { + runID string + turn uint32 + toolPlanID PlanID + planFingerprint string + workspaceFingerprint string + interactionAuthority interaction.Scope + interactionRequester *toolInteractionCapability +} + +type toolInteractionCapability struct{ requester interaction.Requester } + +// NewToolDispatchScope constructs the immutable facts visible to terminal +// guards. An empty workspace fingerprint is allowed only for deliberately +// non-portable embedded engines whose snapshot compatibility identity is empty. +func NewToolDispatchScope( + runID string, + turn uint32, + toolPlanID PlanID, + planFingerprint string, + workspaceFingerprint string, + interactionAuthority interaction.Scope, + interactionRequester interaction.Requester, +) (ToolDispatchScope, error) { + result := ToolDispatchScope{ + runID: runID, turn: turn, toolPlanID: toolPlanID, planFingerprint: planFingerprint, + workspaceFingerprint: workspaceFingerprint, + interactionAuthority: interactionAuthority, + interactionRequester: &toolInteractionCapability{requester: interactionRequester}, + } + if interactionRequester == nil { + return ToolDispatchScope{}, errors.New("tool dispatch interaction requester is required") + } + if err := result.Validate(); err != nil { + return ToolDispatchScope{}, err + } + return result, nil +} + +// Validate rejects incomplete or contradictory dispatch authority. +func (scope ToolDispatchScope) Validate() error { + if scope.runID == "" || scope.runID != strings.TrimSpace(scope.runID) || len(scope.runID) > 96 { + return errors.New("tool dispatch run ID is invalid") + } + if scope.turn == 0 { + return errors.New("tool dispatch turn must be positive") + } + if err := scope.toolPlanID.Validate(); err != nil { + return fmt.Errorf("tool dispatch plan ID: %w", err) + } + if err := validateDispatchFingerprint("plan", scope.planFingerprint, false); err != nil { + return err + } + if err := validateDispatchFingerprint("workspace", scope.workspaceFingerprint, true); err != nil { + return err + } + if err := scope.interactionAuthority.Validate(); err != nil { + return fmt.Errorf("tool dispatch interaction authority: %w", err) + } + if scope.interactionAuthority.RunID() != scope.runID { + return errors.New("tool dispatch interaction authority does not own the run") + } + if scope.interactionRequester == nil || scope.interactionRequester.requester == nil { + return errors.New("tool dispatch interaction requester is required") + } + return nil +} + +func (scope ToolDispatchScope) RunID() string { return scope.runID } +func (scope ToolDispatchScope) Turn() uint32 { return scope.turn } +func (scope ToolDispatchScope) ToolPlanID() PlanID { return scope.toolPlanID } +func (scope ToolDispatchScope) PlanFingerprint() string { return scope.planFingerprint } +func (scope ToolDispatchScope) WorkspaceFingerprint() string { return scope.workspaceFingerprint } +func (scope ToolDispatchScope) InteractionAuthority() interaction.Scope { + return scope.interactionAuthority +} + +// RequestInteraction invokes the run-owned interaction lifecycle without +// exposing broker Scope authority to a guard. +func (scope ToolDispatchScope) RequestInteraction( + ctx context.Context, + request interaction.Request, +) (interaction.Response, error) { + if ctx == nil { + return interaction.Response{}, errors.New("tool dispatch interaction context must not be nil") + } + if err := scope.Validate(); err != nil { + return interaction.Response{}, err + } + if err := request.Validate(); err != nil { + return interaction.Response{}, err + } + if err := ctx.Err(); err != nil { + return interaction.Response{}, err + } + response, err := safeRequestInteraction(ctx, scope.interactionRequester.requester, request.Clone()) + if err != nil { + return interaction.Response{}, err + } + if err = ctx.Err(); err != nil { + return interaction.Response{}, err + } + if err = response.Validate(); err != nil { + return interaction.Response{}, errors.New("tool dispatch interaction response is invalid") + } + if response.ID() != request.ID() { + return interaction.Response{}, errors.New("tool dispatch interaction response does not match the request") + } + return response.Clone(), nil +} + +func validateDispatchFingerprint(label, value string, allowEmpty bool) error { + if value == "" && allowEmpty { + return nil + } + if !strings.HasPrefix(value, dispatchFingerprintPrefix) { + return fmt.Errorf("tool dispatch %s fingerprint must use sha256", label) + } + digest := strings.TrimPrefix(value, dispatchFingerprintPrefix) + if len(digest) != sha256.Size*2 { + return fmt.Errorf("tool dispatch %s fingerprint has an invalid SHA-256 length", label) + } + if _, err := hex.DecodeString(digest); err != nil || strings.ToLower(digest) != digest { + return fmt.Errorf("tool dispatch %s fingerprint must be lowercase hexadecimal SHA-256", label) + } + return nil +} + +func (scope ToolDispatchScope) equal(other ToolDispatchScope) bool { + return scope.runID == other.runID && scope.turn == other.turn && scope.toolPlanID == other.toolPlanID && + scope.planFingerprint == other.planFingerprint && scope.workspaceFingerprint == other.workspaceFingerprint && + scope.interactionAuthority.RunID() == other.interactionAuthority.RunID() && + scope.interactionRequester == other.interactionRequester +} + +func safeRequestInteraction( + ctx context.Context, + requester interaction.Requester, + request interaction.Request, +) (response interaction.Response, err error) { + defer func() { + if recover() != nil { + response = interaction.Response{} + err = errors.New("tool dispatch interaction requester panicked") + } + }() + return requester.Request(ctx, request) +} + +// ToolDispatchNext is a single-use continuation bound to the immutable context, +// scope, definition, call, and reporter received by a guard. +type ToolDispatchNext func() (tool.Result, error) + +// ToolDispatchGuard is the terminal, innermost interception seam for policy. +// Guards may deny or invoke next exactly once. They do not own engine events. +type ToolDispatchGuard interface { + Guard(context.Context, ToolDispatchScope, tool.Definition, tool.Call, ToolDispatchNext) (tool.Result, error) +} + +type guardedToolDispatcher struct { + delegate ToolDispatcher + definitions []tool.Definition + guards []ToolDispatchGuard +} + +type guardDispatchContextKey struct{ dispatcher *guardedToolDispatcher } + +func (dispatcher *guardedToolDispatcher) Definitions() []tool.Definition { + return cloneDefinitions(dispatcher.definitions) +} + +func (dispatcher *guardedToolDispatcher) Definition(name string) (tool.Definition, bool) { + return definitionFromSnapshot(dispatcher.definitions, name) +} + +func (dispatcher *guardedToolDispatcher) Dispatch( + ctx context.Context, + scope ToolDispatchScope, + call tool.Call, + reporter tool.Reporter, +) (tool.Result, error) { + if ctx == nil { + return tool.Result{}, errors.New("tool dispatch context must not be nil") + } + if dispatcher == nil || dispatcher.delegate == nil { + return tool.Result{}, errors.New("guarded tool dispatcher is nil") + } + if err := scope.Validate(); err != nil { + return tool.Result{}, err + } + bound, ok := ctx.Value(toolDispatchAuthorityContextKey{}).(ToolDispatchScope) + if !ok || !bound.equal(scope) { + return tool.Result{}, errors.New("tool dispatch scope was substituted after authority binding") + } + if err := call.Validate(); err != nil { + return tool.Result{}, err + } + if err := ctx.Err(); err != nil { + return tool.Result{}, err + } + definition, declared := dispatcher.Definition(call.Name()) + if !declared { + return tool.Result{}, fmt.Errorf("tool %q is not declared by the guarded plan", call.Name()) + } + key := guardDispatchContextKey{dispatcher: dispatcher} + if ctx.Value(key) != nil { + return tool.Result{}, errors.New("tool dispatch guard re-entry is forbidden") + } + ctx = context.WithValue(ctx, key, struct{}{}) + var invoke func(context.Context, int) (tool.Result, error) + invoke = func(guardContext context.Context, index int) (tool.Result, error) { + if err := guardContext.Err(); err != nil { + return tool.Result{}, err + } + if index == len(dispatcher.guards) { + return dispatcher.delegate.Dispatch(guardContext, scope, call, reporter) + } + continuation, closeContinuation := newGuardContinuation(func() (tool.Result, error) { + return invoke(guardContext, index+1) + }) + result, guardErr := safeGuard( + dispatcher.guards[index], guardContext, scope, definition, call, continuation, + ) + closeContinuation() + return validateGuardOutcome(call, result, guardErr) + } + return invoke(ctx, 0) +} + +func newGuardContinuation(delegate ToolDispatchNext) (ToolDispatchNext, func()) { + var state atomic.Uint32 + done := make(chan struct{}) + return func() (tool.Result, error) { + if !state.CompareAndSwap(0, 1) { + return tool.Result{}, errors.New("tool dispatch continuation is closed or was already invoked") + } + defer func() { + state.Store(2) + close(done) + }() + return delegate() + }, func() { + if state.CompareAndSwap(0, 2) { + return + } + if state.Load() == 1 { + <-done + } + } +} + +func validateGuardOutcome(call tool.Call, result tool.Result, guardErr error) (tool.Result, error) { + if guardErr != nil { + if !result.IsZero() { + return tool.Result{}, errors.New("tool dispatch guard returned both a result and an error") + } + return tool.Result{}, guardErr + } + if err := result.Validate(); err != nil { + return tool.Result{}, fmt.Errorf("validate tool dispatch guard result: %w", err) + } + if result.CallID() != call.ID() { + return tool.Result{}, errors.New("tool dispatch guard result does not match the active call") + } + return result.Clone(), nil +} + +func safeGuard( + guard ToolDispatchGuard, + ctx context.Context, + scope ToolDispatchScope, + definition tool.Definition, + call tool.Call, + next ToolDispatchNext, +) (result tool.Result, err error) { + defer func() { + if recover() != nil { + result = tool.Result{} + err = errors.New("tool dispatch guard panicked") + } + }() + return guard.Guard(ctx, scope, definition.Clone(), call.Clone(), next) +} diff --git a/vendor/github.com/spice-framework/spice-agent/stage/plan.go b/vendor/github.com/spice-framework/spice-agent/stage/plan.go index e30b4b5..a993bce 100644 --- a/vendor/github.com/spice-framework/spice-agent/stage/plan.go +++ b/vendor/github.com/spice-framework/spice-agent/stage/plan.go @@ -93,7 +93,7 @@ func NewToolPlanLease(id PlanID, dispatcher ToolDispatcher, release func() error if err != nil { return nil, fmt.Errorf("snapshot tool plan %q definitions: %w", id, err) } - snapshot := &definitionSnapshotDispatcher{delegate: dispatcher, definitions: definitions} + snapshot := &definitionSnapshotDispatcher{delegate: dispatcher, definitions: definitions, planID: id} return &ToolPlanLease{ id: id, dispatcher: snapshot, definitions: definitions, release: release, releaseDone: make(chan struct{}), @@ -198,6 +198,10 @@ func NewStaticToolPlanSource(dispatcher ToolDispatcher) (*StaticToolPlanSource, if dispatcher == nil { return nil, errors.New("static tool plan source requires a dispatcher") } + dispatcher, err := ApplyToolDispatchPipeline(dispatcher, nil, nil) + if err != nil { + return nil, fmt.Errorf("guard static tool plan: %w", err) + } definitions, err := snapshotDefinitions(dispatcher) if err != nil { return nil, fmt.Errorf("snapshot static tool plan: %w", err) @@ -212,6 +216,76 @@ func NewStaticToolPlanSource(dispatcher ToolDispatcher) (*StaticToolPlanSource, }, nil } +// ApplyToolDispatchPipeline installs terminal guards exactly once closest to +// the merged base, then applies trusted decorators with the first decorator +// outermost. A composed dispatcher cannot be composed again. +func ApplyToolDispatchPipeline( + base ToolDispatcher, + guards []ToolDispatchGuard, + decorators []ToolDispatchDecorator, +) (ToolDispatcher, error) { + if base == nil { + return nil, errors.New("tool dispatch pipeline requires a base dispatcher") + } + if _, composed := base.(*composedToolDispatcher); composed { + if len(guards) == 0 && len(decorators) == 0 { + return base, nil + } + return nil, errors.New("tool dispatch pipeline is already composed") + } + definitions, err := snapshotDefinitions(base) + if err != nil { + return nil, fmt.Errorf("snapshot guarded tool dispatcher: %w", err) + } + guardCopy := append([]ToolDispatchGuard(nil), guards...) + for index, guard := range guardCopy { + if guard == nil { + return nil, fmt.Errorf("tool dispatch guard %d is nil", index) + } + } + guarded := &guardedToolDispatcher{delegate: base, definitions: definitions, guards: guardCopy} + decorated, err := ApplyToolDispatchDecorators(guarded, decorators) + if err != nil { + return nil, err + } + return &composedToolDispatcher{delegate: decorated}, nil +} + +type toolDispatchAuthorityContextKey struct{} + +type composedToolDispatcher struct{ delegate ToolDispatcher } + +func (dispatcher *composedToolDispatcher) Definitions() []tool.Definition { + if dispatcher == nil || dispatcher.delegate == nil { + return []tool.Definition{} + } + return dispatcher.delegate.Definitions() +} + +func (dispatcher *composedToolDispatcher) Definition(name string) (tool.Definition, bool) { + if dispatcher == nil || dispatcher.delegate == nil { + return tool.Definition{}, false + } + return dispatcher.delegate.Definition(name) +} + +func (dispatcher *composedToolDispatcher) Dispatch(ctx context.Context, scope ToolDispatchScope, call tool.Call, reporter tool.Reporter) (tool.Result, error) { + if ctx == nil { + return tool.Result{}, errors.New("tool dispatch context must not be nil") + } + if dispatcher == nil || dispatcher.delegate == nil { + return tool.Result{}, errors.New("composed tool dispatcher is nil") + } + if err := scope.Validate(); err != nil { + return tool.Result{}, err + } + if ctx.Value(toolDispatchAuthorityContextKey{}) != nil { + return tool.Result{}, errors.New("tool dispatch re-entry with already-bound authority is forbidden") + } + bound := context.WithValue(ctx, toolDispatchAuthorityContextKey{}, scope) + return dispatcher.delegate.Dispatch(bound, scope, call, reporter) +} + // LeaseCurrent leases the one static generation. func (source *StaticToolPlanSource) LeaseCurrent(ctx context.Context) (*ToolPlanLease, error) { if ctx == nil { @@ -278,9 +352,24 @@ func ApplyToolDispatchDecorators( return current, nil } +// SnapshotToolDispatcher freezes a dispatcher's declared definitions while +// preserving its trusted executable behavior. It does not install guards or +// decorators and therefore is not a substitute for ApplyToolDispatchPipeline. +func SnapshotToolDispatcher(dispatcher ToolDispatcher) (ToolDispatcher, error) { + if dispatcher == nil { + return nil, errors.New("tool dispatcher snapshot requires a dispatcher") + } + definitions, err := snapshotDefinitions(dispatcher) + if err != nil { + return nil, fmt.Errorf("snapshot tool dispatcher definitions: %w", err) + } + return &definitionSnapshotDispatcher{delegate: dispatcher, definitions: definitions}, nil +} + type definitionSnapshotDispatcher struct { delegate ToolDispatcher definitions []tool.Definition + planID PlanID } func (dispatcher *definitionSnapshotDispatcher) Definitions() []tool.Definition { @@ -305,6 +394,7 @@ func (dispatcher *definitionSnapshotDispatcher) Definition(name string) (tool.De func (dispatcher *definitionSnapshotDispatcher) Dispatch( ctx context.Context, + scope ToolDispatchScope, call tool.Call, reporter tool.Reporter, ) (tool.Result, error) { @@ -314,10 +404,23 @@ func (dispatcher *definitionSnapshotDispatcher) Dispatch( if err := call.Validate(); err != nil { return tool.Result{}, err } + if dispatcher.planID != "" && scope.ToolPlanID() != dispatcher.planID { + return tool.Result{}, fmt.Errorf("tool dispatch plan %q does not match leased plan %q", scope.ToolPlanID(), dispatcher.planID) + } if _, declared := dispatcher.Definition(call.Name()); !declared { return tool.Result{}, fmt.Errorf("tool %q is not declared by the leased plan", call.Name()) } - return dispatcher.delegate.Dispatch(ctx, call, reporter) + return dispatcher.delegate.Dispatch(ctx, scope, call, reporter) +} + +func definitionFromSnapshot(definitions []tool.Definition, name string) (tool.Definition, bool) { + index, found := slices.BinarySearchFunc(definitions, name, func(definition tool.Definition, target string) int { + return strings.Compare(definition.Name(), target) + }) + if !found { + return tool.Definition{}, false + } + return definitions[index].Clone(), true } func snapshotDefinitions(dispatcher ToolDispatcher) (definitions []tool.Definition, err error) { diff --git a/vendor/github.com/spice-framework/spice-agent/stage/stage.go b/vendor/github.com/spice-framework/spice-agent/stage/stage.go index 64275d8..570ebe4 100644 --- a/vendor/github.com/spice-framework/spice-agent/stage/stage.go +++ b/vendor/github.com/spice-framework/spice-agent/stage/stage.go @@ -23,7 +23,7 @@ type Stage[Input, Output any] interface { type ToolDispatcher interface { Definitions() []tool.Definition Definition(name string) (tool.Definition, bool) - Dispatch(context.Context, tool.Call, tool.Reporter) (tool.Result, error) + Dispatch(context.Context, ToolDispatchScope, tool.Call, tool.Reporter) (tool.Result, error) } // ToolDispatchDecorator wraps the canonical dispatcher. Spice supplies these @@ -127,13 +127,16 @@ func (dispatcher *Dispatcher) Definition(name string) (tool.Definition, bool) { // Dispatch validates correlation and cancellation around one trusted in-process // call. Cancellation is cooperative: a Tool that ignores ctx can still block its // own goroutine and therefore must be treated as trusted code. -func (dispatcher *Dispatcher) Dispatch(ctx context.Context, call tool.Call, reporter tool.Reporter) (tool.Result, error) { +func (dispatcher *Dispatcher) Dispatch(ctx context.Context, scope ToolDispatchScope, call tool.Call, reporter tool.Reporter) (tool.Result, error) { if ctx == nil { return tool.Result{}, errors.New("tool dispatch context must not be nil") } if dispatcher == nil { return tool.Result{}, errors.New("tool dispatcher is nil") } + if err := scope.Validate(); err != nil { + return tool.Result{}, err + } if err := call.Validate(); err != nil { return tool.Result{}, err } diff --git a/vendor/modules.txt b/vendor/modules.txt index 28e6209..bee44f8 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -12,8 +12,9 @@ github.com/spice-framework/spice/security github.com/spice-framework/spice/starter github.com/spice-framework/spice/validation github.com/spice-framework/spice/web -# github.com/spice-framework/spice-agent v0.1.0-preview.4 +# github.com/spice-framework/spice-agent v0.1.0-preview.5 ## explicit; go 1.26.0 +github.com/spice-framework/spice-agent/interaction github.com/spice-framework/spice-agent/process github.com/spice-framework/spice-agent/stage github.com/spice-framework/spice-agent/tool