From d2c4a9980205573f4b13b317b407e07e86f50f65 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 4 Oct 2026 20:28:58 +0800 Subject: [PATCH] require explicit Agent no-dial proof before releasing quota --- deploys/test/nonprod-call-evidence.sh | 4 +++- .../nonprod-shuqi-attempts-20261004.md | 4 ++++ .../config/nonprod_evidence_cleanup_test.go | 16 ++++++++++++-- internal/dispatcher/execute.go | 22 ++++++++++++++----- internal/dispatcher/execute_test.go | 20 ++++++++++++++++- internal/rpc/approved_execution.go | 16 ++++++++++++-- internal/rpc/approved_execution_test.go | 16 ++++++++++++++ 7 files changed, 87 insertions(+), 11 deletions(-) diff --git a/deploys/test/nonprod-call-evidence.sh b/deploys/test/nonprod-call-evidence.sh index 9380989..e2c7ee2 100755 --- a/deploys/test/nonprod-call-evidence.sh +++ b/deploys/test/nonprod-call-evidence.sh @@ -90,7 +90,9 @@ require_call_window() { exit 1 fi } -require_call_window +# A diagnostic that exits before the call command can run off-hours; every +# real attempt still checks the time gate here and again immediately pre-dial. +if (( ! preflight_only )); then require_call_window; fi # "any" includes both provider SIP and the local Asterisk ExternalMedia RTP. # Reducing it to the default-route NIC silently omits loopback media. if [[ -z "$evidence_dir" ]]; then diff --git a/docs/evidence/nonprod-shuqi-attempts-20261004.md b/docs/evidence/nonprod-shuqi-attempts-20261004.md index 68bbf2c..0ac0816 100644 --- a/docs/evidence/nonprod-shuqi-attempts-20261004.md +++ b/docs/evidence/nonprod-shuqi-attempts-20261004.md @@ -28,3 +28,7 @@ 使用者明确选择:确定在 Agent 接纳前被拒、或 Agent 能证明 ARI originate **从未提交**的执行应释放占用;其他 ARI/投递结果未知仍保留;隔离测试租户经获批将并发额度从 1 调为 2,以保留旧占用 1 并允许另一个任务/线路 1 个名额。分支 `fix/nonprod-definite-failure-release` 的本地修改:Dispatcher 对 Agent 明确的拨号前拒绝写入持久 `rejected` 回执并释放额度;Agent 对拨号前确定未提交的失败通过带会话校验的正式 `ReportCallEnded` 确认终结;SaaS 测试等待器持久保存并确认 `rejected` 回执后立即结束,不伪造通话结果。未确认 ARI originate 的结果不自动释放、不重试。已在测试机私有目录新建 `data-quota2-20261004/`,只更改租户并发上限为 2 且递增额度 revision,通过离线合同校验;**原始 `data/` 未修改,新快照未启用**。 本地 `make check`、`make release-check-local`、隔离 RabbitMQ 测试及业务单元覆盖率 **69.0%** 通过;仍没有新版主机部署、真实拨号或 LLM 应答证据,旧执行保留。此代码修复不构成旧执行已终结的证明,也不授权绕过当日 `20:00` 截止门禁。 + +## 合并 main 后的边界审查 + +按使用者明确指定的先后顺序,先将修复分支快进合并并推送到 `main`,再于不可呼出时段审查。发现一处关键误判:先前按 Agent 的 `FailedPrecondition` 等普通错误码直接释放占用;**同一错误码也用于拒绝重拨结果未知的旧执行**。已用回归测试先复现,再改为仅接受 Agent 明确标注“本次调用从未提交拨号”的机器可读回报,缺少标注一律仍占用;绑定及错误包装后的场景均有测试。另调整抓包脚本:`--preflight-only` 可以在禁止呼出的时段执行只读/不拨号诊断,真实拨号仍需通过开始和临拨前两次时段检查;有相应的时段回归测试。本地 `make check`、`make release-check-local`、隔离 MQ 测试和业务覆盖率 **69.1%** 通过。本次审查与测试没有发起真实呼叫;主机部署及新版诊断需要另留实际证据。 diff --git a/internal/config/nonprod_evidence_cleanup_test.go b/internal/config/nonprod_evidence_cleanup_test.go index 8177e36..5b8b13f 100644 --- a/internal/config/nonprod_evidence_cleanup_test.go +++ b/internal/config/nonprod_evidence_cleanup_test.go @@ -15,7 +15,7 @@ func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) { tools := t.TempDir() for name, script := range map[string]string{ "id": "if [ \"$1\" = -u ]; then echo 0; else exec /usr/bin/id \"$@\"; fi\n", - "date": "if [ \"${TZ-}\" = Asia/Shanghai ] && [ \"$1\" = +%H%M ]; then echo 1000; else exec /usr/bin/date \"$@\"; fi\n", + "date": "if [ \"${TZ-}\" = Asia/Shanghai ] && [ \"$1\" = +%H%M ]; then echo 2100; else exec /usr/bin/date \"$@\"; fi\n", } { if err := os.WriteFile(filepath.Join(tools, name), []byte("#!/bin/sh\n"+script), 0700); err != nil { t.Fatal(err) @@ -33,7 +33,19 @@ func TestNonprodUserAsteriskScopeRequiresExplicitConfig(t *testing.T) { command.Env = append(os.Environ(), "PATH="+tools+":"+os.Getenv("PATH"), "ASTERISK_BIN=/bin/true", "ASTERISK_CONFIG=") output, err := command.CombinedOutput() if err == nil || !strings.Contains(string(output), "explicit user Asterisk configuration required") { - t.Fatalf("user-scope diagnostics must refuse unbound Asterisk instance: err=%v output=%s", err, output) + t.Fatalf("off-hours non-dial preflight must check Asterisk configuration: err=%v output=%s", err, output) + } + withoutPreflight := exec.Command("bash", "../../deploys/test/nonprod-call-evidence.sh", "--environment", "mock", + "--asterisk-scope", "user", "--trunk", "provider-primary", "--target", "15003164745", "--run-as", currentUser.Username, + "--interface", "lo", "--recording-dir", filepath.Join(root, "recordings"), "--evidence-dir", filepath.Join(root, "real-attempt"), + "--attempt-ledger", filepath.Join(root, "attempts.tsv"), "--", "/bin/true") + withoutPreflight.Env = command.Env + output, err = withoutPreflight.CombinedOutput() + if err == nil || !strings.Contains(string(output), "outside Asia/Shanghai 09:00-20:00") { + t.Fatalf("real call must remain blocked outside hours: err=%v output=%s", err, output) + } + if _, err := os.Stat(filepath.Join(root, "attempts.tsv")); !os.IsNotExist(err) { + t.Fatalf("out-of-hours real call reserved an attempt: %v", err) } } diff --git a/internal/dispatcher/execute.go b/internal/dispatcher/execute.go index b51bb0a..798a2ac 100644 --- a/internal/dispatcher/execute.go +++ b/internal/dispatcher/execute.go @@ -11,6 +11,7 @@ import ( "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -130,6 +131,19 @@ func (c *ExecuteController) ProcessPending(ctx context.Context) error { return errors.Join(failures...) } +func agentProvedCallNotIssued(err error) bool { + st, ok := status.FromError(err) + if !ok || st.Code() == codes.OK { + return false + } + for _, detail := range st.Details() { + if proof, ok := detail.(*errdetails.ErrorInfo); ok && proof.Reason == "GO_SIP_CALL_NOT_ISSUED" && proof.Domain == "agent.go-sip" { + return true + } + } + return false +} + func (c *ExecuteController) dispatchPending(ctx context.Context, cmd store.ExecuteCommand) error { at := c.Now() issued, err := time.Parse(time.RFC3339Nano, cmd.IssuedAt) @@ -208,17 +222,15 @@ func (c *ExecuteController) dispatchPending(ctx context.Context, cmd store.Execu Deadline: choice.Deadline, Snapshot: snapshot, } if err := c.Originator.Originate(ctx, spec); err != nil { - switch status.Code(err) { - case codes.FailedPrecondition, codes.InvalidArgument, codes.PermissionDenied: + if agentProvedCallNotIssued(err) { rejectErr := c.Store.RejectUnissuedExecute(cmd.DispatcherID, cmd.EventID, "Agent refused before outbound call") if rejectErr == nil { log.Printf("Dispatcher call refused before dial: event_id=%q agent_code=%s", cmd.EventID, status.Code(err)) } return errors.Join(fmt.Errorf("Agent rejected call %q before dialing: %w", cmd.EventID, err), rejectErr) - default: - unknownErr := c.Store.MarkExecuteUnknown(cmd.DispatcherID, cmd.EventID) - return errors.Join(fmt.Errorf("originator outcome unknown for call %q: %w", cmd.EventID, err), unknownErr) } + unknownErr := c.Store.MarkExecuteUnknown(cmd.DispatcherID, cmd.EventID) + return errors.Join(fmt.Errorf("originator outcome unknown for call %q: %w", cmd.EventID, err), unknownErr) } if err := c.Store.MarkExecuteDispatched(cmd.DispatcherID, cmd.EventID); err != nil { return fmt.Errorf("originated call %q has no durable acknowledgment: %w", cmd.EventID, err) diff --git a/internal/dispatcher/execute_test.go b/internal/dispatcher/execute_test.go index 52f1dfa..40c2515 100644 --- a/internal/dispatcher/execute_test.go +++ b/internal/dispatcher/execute_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "path/filepath" "strings" "testing" @@ -12,6 +13,7 @@ import ( "git.ipao.vip/rogee/go-sip/internal/configread" "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/store" + "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -138,9 +140,25 @@ func TestExecuteWaitsForRulesThenDispatchesOriginalIdentity(t *testing.T) { } } +func TestAgentFailedPreconditionWithoutNoIssueProofKeepsUnknownReservation(t *testing.T) { + controller, originator, _, s := newExecuteFixture(t) + originator.err = status.Error(codes.FailedPrecondition, "prior Agent execution outcome is unknown; no redial") + if err := controller.ProcessExecute(context.Background(), executeBody(t, "prior-agent-unknown-1", "15003164745")); err == nil { + t.Fatal("prior unknown was silently treated as confirmed rejection") + } + occupied, err := s.TrunkOccupancy(controller.DispatcherID) + if err != nil || occupied["trunk-mock"] != 1 { + t.Fatalf("unproven Agent state was released: %+v %v", occupied, err) + } +} + func TestAgentExplicitRefusalBeforeDialReleasesReservationAndKeepsUnknownBlocked(t *testing.T) { controller, originator, _, s := newExecuteFixture(t) - originator.err = status.Error(codes.FailedPrecondition, "Agent refused before issue") + confirmed, err := status.New(codes.FailedPrecondition, "Agent refused before issue").WithDetails(&errdetails.ErrorInfo{Reason: "GO_SIP_CALL_NOT_ISSUED", Domain: "agent.go-sip"}) + if err != nil { + t.Fatal(err) + } + originator.err = fmt.Errorf("signed Agent RPC: %w", confirmed.Err()) body := executeBody(t, "agent-refused-1", "15003164745") if err := controller.ProcessExecute(context.Background(), body); err == nil { t.Fatal("Agent refusal hidden") diff --git a/internal/rpc/approved_execution.go b/internal/rpc/approved_execution.go index 75d1b66..13d3fb2 100644 --- a/internal/rpc/approved_execution.go +++ b/internal/rpc/approved_execution.go @@ -16,6 +16,7 @@ import ( "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" @@ -73,6 +74,17 @@ func (s *Server) GetLoadedSIP(ctx context.Context, req *agentpb.GetLoadedSIPRequ // 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") @@ -151,10 +163,10 @@ func (s *Server) ExecuteApproved(ctx context.Context, req *agentpb.ExecuteApprov 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, status.Error(codes.DeadlineExceeded, "Dispatcher dial authorization expired before issuing") + 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, status.Error(codes.FailedPrecondition, "approved call was not ready before dial") + 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") diff --git a/internal/rpc/approved_execution_test.go b/internal/rpc/approved_execution_test.go index 8de6305..271207a 100644 --- a/internal/rpc/approved_execution_test.go +++ b/internal/rpc/approved_execution_test.go @@ -12,10 +12,26 @@ import ( agentpb "git.ipao.vip/rogee/go-sip/gen/agent" "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" ) +func TestAgentDefiniteNoDialStatusHasExplicitMachineReadableEvidence(t *testing.T) { + err := approvedNoIssueError(codes.FailedPrecondition, "call was not ready") + st, ok := status.FromError(err) + if !ok || st.Code() != codes.FailedPrecondition { + t.Fatalf("explicit refusal must retain gRPC code: %v", err) + } + if len(st.Details()) != 1 { + t.Fatalf("missing signed no-dial fact: %v", st.Details()) + } + info, ok := st.Details()[0].(*errdetails.ErrorInfo) + if !ok || info.Reason != "GO_SIP_CALL_NOT_ISSUED" || info.Domain != "agent.go-sip" { + t.Fatalf("wrong no-call fact: %v", st.Details()) + } +} + func approvedTestRequest(t *testing.T, now time.Time) *agentpb.ExecuteApprovedRequest { t.Helper() taskJSON, err := os.ReadFile("../../contracts/local/examples/config-read-task-asr.json")