Files
go-sip/internal/rpc/approved_execution.go
T

190 lines
8.4 KiB
Go

package rpc
import (
"context"
"encoding/json"
"errors"
"log"
"math"
"time"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"git.ipao.vip/rogee/go-sip/internal/agent"
"git.ipao.vip/rogee/go-sip/internal/ai"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/contract"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ApprovedExecution is the frozen Agent-side call handed to the isolated
// adapter. It contains plaintext provider credentials in AI; never log it.
type ApprovedExecution struct {
DispatcherID string
TenantID int64
TaskID string
SourceEventID string
CallID string
SelectedTrunkID string
CallerID string
Callee string
DialedCallee string
SIPRevision int64
RingTimeout time.Duration
MaxCallDuration time.Duration
DialBefore time.Time
AI ai.Binding
}
// GetLoadedSIP reports the adapter's observed state, not the desired static
// configuration. Missing or stale observation cannot authorize origination.
func (s *Server) GetLoadedSIP(ctx context.Context, req *agentpb.GetLoadedSIPRequest) (*agentpb.GetLoadedSIPResponse, error) {
if req == nil {
return nil, status.Error(codes.InvalidArgument, "SIP inspection request is required")
}
if err := s.authorize(ctx, req.Meta); err != nil {
return nil, err
}
if _, err := s.sessions.ApprovedDispatcher(req.Meta, s.now()); err != nil {
return nil, err
}
if s.loadedSIP == nil {
return nil, status.Error(codes.FailedPrecondition, "observed SIP load state is unavailable")
}
loaded, err := s.loadedSIP(ctx)
if err != nil || len(loaded) == 0 {
return nil, status.Error(codes.Unavailable, "observed SIP load state failed")
}
copyOfLoaded := make(map[string]int64, len(loaded))
for trunk, revision := range loaded {
if trunk == "" || revision <= 0 {
return nil, status.Error(codes.FailedPrecondition, "observed SIP load state is invalid")
}
copyOfLoaded[trunk] = revision
}
return &agentpb.GetLoadedSIPResponse{TrunkRevision: copyOfLoaded}, nil
}
// ExecuteApproved validates the frozen call before handing it to the selected
// adapter. Dispatcher owns durable protection for unfinished executions; the
// Agent worker prevents overlapping active calls without permanent markers.
// approvedNoIssueError is only used when the Agent can prove it has not
// submitted an ARI originate. A bare gRPC status must never free quota:
// replays of an earlier unknown attempt can return FailedPrecondition too.
func approvedNoIssueError(code codes.Code, message string) error {
marked, err := status.New(code, message).WithDetails(&errdetails.ErrorInfo{Reason: "GO_SIP_CALL_NOT_ISSUED", Domain: "agent.go-sip"})
if err != nil {
return status.Error(codes.Internal, "cannot encode definite pre-dial rejection")
}
return marked.Err()
}
func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprovedRequest) (*agentpb.ExecuteApprovedResponse, error) {
if req == nil || req.Meta == nil {
return nil, status.Error(codes.InvalidArgument, "approved execution metadata is required")
}
if err := s.authorize(ctx, req.Meta); err != nil {
return nil, err
}
dispatcherID, err := s.sessions.ApprovedDispatcher(req.Meta, s.now())
if err != nil {
return nil, err
}
if req.DispatcherId != dispatcherID {
return nil, status.Error(codes.PermissionDenied, "Dispatcher does not own the active Agent session")
}
originate := s.mockApprovedOriginate
switch s.mode {
case "mock":
case "nonprod-real":
originate = s.nonprodRealOriginate
default:
return nil, status.Error(codes.FailedPrecondition, "approved origination mode is disabled")
}
if originate == nil {
return nil, status.Error(codes.FailedPrecondition, "approved origination adapter is unavailable")
}
const maxTimeoutMS = int64(math.MaxInt64 / int64(time.Millisecond))
if req.TenantId <= 0 || req.TaskId == "" || req.SourceEventId == "" || req.CallId == "" || req.SelectedTrunkId == "" || req.CallerId == "" || req.Callee == "" || req.DialedCallee == "" || req.RingTimeoutMs <= 0 || req.RingTimeoutMs > maxTimeoutMS || req.MaxCallDurationMs <= 0 || req.MaxCallDurationMs > maxTimeoutMS || req.SipRevision <= 0 || req.Meta.IdempotencyKey != req.SourceEventId {
return nil, status.Error(codes.InvalidArgument, "approved execution identity or dial decision is incomplete")
}
if req.DialBeforeUnixMs <= s.now().UnixMilli() {
return nil, status.Error(codes.DeadlineExceeded, "Dispatcher dial authorization expired")
}
hash, err := configread.ExecutionBindingSHA256(req.TaskConfigJson, req.ProvidersJson, req.SipRevision)
if err != nil || hash != req.BindingSha256 {
return nil, status.Error(codes.FailedPrecondition, "bound execution snapshot does not match")
}
if err := contract.ValidateCurrent("config-read", req.TaskConfigJson); err != nil {
return nil, status.Error(codes.InvalidArgument, "approved task schema is invalid")
}
var task configread.Task
if err := json.Unmarshal(req.TaskConfigJson, &task); err != nil {
return nil, status.Error(codes.InvalidArgument, "approved task JSON is invalid")
}
if task.DispatcherID != dispatcherID || task.TenantID != req.TenantId || task.TaskID != req.TaskId {
return nil, status.Error(codes.FailedPrecondition, "approved task identity does not match")
}
limits, err := task.TrunkLimits()
if err != nil || limits[req.SelectedTrunkId] <= 0 {
return nil, status.Error(codes.FailedPrecondition, "selected trunk is not enabled for this task")
}
var providers map[string]configread.Provider
if err := json.Unmarshal(req.ProvidersJson, &providers); err != nil {
return nil, status.Error(codes.InvalidArgument, "approved provider JSON is invalid")
}
bound, err := ai.Bind(task, providers)
if err != nil {
// Errors from externally supplied snapshots must not echo a prompt,
// conversation, provider credential, or vendor endpoint into logs.
return nil, status.Error(codes.FailedPrecondition, "approved AI settings cannot run on this Agent")
}
if err := s.requireApprovedSIP(ctx, req.SelectedTrunkId, req.SipRevision); err != nil {
return nil, err
}
if req.DialBeforeUnixMs <= s.now().UnixMilli() {
return nil, status.Error(codes.DeadlineExceeded, "Dispatcher dial authorization expired before issuing")
}
if err := s.requireApprovedSIP(ctx, req.SelectedTrunkId, req.SipRevision); err != nil {
return nil, err
}
approved := ApprovedExecution{
DispatcherID: dispatcherID, TenantID: req.TenantId, TaskID: req.TaskId,
SourceEventID: req.SourceEventId, CallID: req.CallId,
SelectedTrunkID: req.SelectedTrunkId, CallerID: req.CallerId,
Callee: req.Callee, DialedCallee: req.DialedCallee, SIPRevision: req.SipRevision,
RingTimeout: time.Duration(req.RingTimeoutMs) * time.Millisecond,
MaxCallDuration: time.Duration(req.MaxCallDurationMs) * time.Millisecond,
DialBefore: time.UnixMilli(req.DialBeforeUnixMs), AI: bound,
}
if err := originate(ctx, approved); err != nil {
switch {
case errors.Is(err, ErrApprovedDialExpired):
log.Printf("Agent approved execution refused before dial: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
return nil, approvedNoIssueError(codes.DeadlineExceeded, "Dispatcher dial authorization expired before issuing")
case errors.Is(err, agent.ErrTaskAdmissionClosed), errors.Is(err, agent.ErrTaskStopped), errors.Is(err, ErrApprovedCallPreparation):
log.Printf("Agent approved execution refused before dial: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
return nil, approvedNoIssueError(codes.FailedPrecondition, "approved call was not ready before dial")
default:
log.Printf("Agent approved execution outcome unknown: event_id=%q task_id=%q cause_type=%T", req.SourceEventId, req.TaskId, err)
return nil, status.Error(codes.Unavailable, "approved execution outcome unknown")
}
}
return &agentpb.ExecuteApprovedResponse{CallId: req.CallId, Accepted: true}, nil
}
func (s *Server) requireApprovedSIP(ctx context.Context, trunk string, revision int64) error {
if s.loadedSIP == nil {
return status.Error(codes.FailedPrecondition, "observed SIP load state is unavailable")
}
loaded, err := s.loadedSIP(ctx)
if err != nil {
return status.Error(codes.Unavailable, "observed SIP load state failed")
}
if loaded[trunk] != revision {
return status.Error(codes.FailedPrecondition, "selected SIP revision is not actually loaded")
}
return nil
}