package rpc import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "log" "math" "os" "path/filepath" "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" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) // 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 CallerProfileID 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 is mock-only until real Agent/Asterisk loading and supplier // contracts have been separately verified. It persists a one-shot unknown // execution BEFORE calling the selected adapter. Ambiguous attempts never redial. // 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") } 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") } 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 err := s.recordApprovedAttempt(req); 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, CallerProfileID: task.CallerProfileID, 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 } type approvedAttempt struct { Digest string `json:"digest"` State string `json:"state"` } // recordApprovedAttempt writes only a digest. The request body (including // plaintext provider credentials) is never placed in the Agent journal. func (s *Server) recordApprovedAttempt(req *agentpb.ExecuteApprovedRequest) error { if s.approvedJournalDir == "" { return status.Error(codes.FailedPrecondition, "durable Agent execution directory is required") } copyOfRequest := proto.Clone(req).(*agentpb.ExecuteApprovedRequest) copyOfRequest.Meta = nil // a refreshed session must not change call identity body, err := (proto.MarshalOptions{Deterministic: true}).Marshal(copyOfRequest) if err != nil { return status.Error(codes.Internal, "compute approved execution digest") } digest := sha256.Sum256(body) key, err := json.Marshal(struct { Dispatcher string `json:"dispatcher"` Tenant int64 `json:"tenant"` Task string `json:"task"` Event string `json:"event"` }{req.DispatcherId, req.TenantId, req.TaskId, req.SourceEventId}) if err != nil { return status.Error(codes.Internal, "compute approved execution identity") } identity := sha256.Sum256(key) path := filepath.Join(s.approvedJournalDir, hex.EncodeToString(identity[:])+".json") record, err := json.Marshal(approvedAttempt{Digest: hex.EncodeToString(digest[:]), State: "unknown"}) if err != nil { return status.Error(codes.Internal, "serialize approved execution identity") } s.approvedMu.Lock() defer s.approvedMu.Unlock() if err := os.MkdirAll(s.approvedJournalDir, 0700); err != nil { return status.Errorf(codes.Internal, "create Agent execution directory: %v", err) } if err := syncApprovedDir(filepath.Dir(s.approvedJournalDir)); err != nil { return status.Errorf(codes.Internal, "sync Agent execution parent directory: %v", err) } file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if errors.Is(err, os.ErrExist) { existingBytes, readErr := os.ReadFile(path) if readErr != nil { return status.Error(codes.FailedPrecondition, "prior Agent execution state is unreadable") } var existing approvedAttempt if json.Unmarshal(existingBytes, &existing) != nil || existing.State != "unknown" || existing.Digest == "" { return status.Error(codes.FailedPrecondition, "prior Agent execution state is incomplete") } if existing.Digest != hex.EncodeToString(digest[:]) { return status.Error(codes.AlreadyExists, "execution identity has conflicting immutable content") } return status.Error(codes.FailedPrecondition, "prior Agent execution outcome is unknown; no redial") } if err != nil { return status.Errorf(codes.Internal, "create Agent execution identity: %v", err) } if _, err := file.Write(record); err != nil { _ = file.Close() return status.Errorf(codes.Internal, "persist Agent execution identity: %v", err) } if err := file.Sync(); err != nil { _ = file.Close() return status.Errorf(codes.Internal, "sync Agent execution identity: %v", err) } if err := file.Close(); err != nil { return status.Errorf(codes.Internal, "close Agent execution identity: %v", err) } if err := syncApprovedDir(s.approvedJournalDir); err != nil { return status.Errorf(codes.Internal, "sync Agent execution directory: %v", err) } return nil } func syncApprovedDir(path string) error { dir, err := os.Open(path) if err != nil { return err } err = dir.Sync() return errors.Join(err, dir.Close()) }