diff --git a/AGENTS.md b/AGENTS.md index 7119a27..896ab1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ - P01–P08 及 K01–K16 已完成**项目内隔离 Mock** 核验;本轮把分散的人类可读契约、文档与第三方对接合为唯一当前规范,不重审已确认规则。若未来另获启动开发/审查子 Agent 授权,必须按使用者指定的 `gpt-5.6-luna`、`max` 思考和 `fast: true` 逐项核验并显式配置,不静默换模型、降档或关闭 fast。 - 唯一现行 SaaS↔Dispatcher 业务规范是 [`docs/thirds/saas-dispatcher.md`](docs/thirds/saas-dispatcher.md);当前项目内 Schema、拓扑、正反例及来源/hash 在 [`contracts/local/`](contracts/local/);内部 Agent RPC 在 [`proto/agent/agent.proto`](proto/agent/agent.proto)。本地验收与外部缺口见 [`docs/evidence/saas-dispatcher-p08-acceptance.md`](docs/evidence/saas-dispatcher-p08-acceptance.md)。Markdown 不代替机器合同或外部签收,也不另外维护一份平行字段定义。 - 已由当前合同来源清单固定哈希的历史提案与计划保留**原字节**于 [`docs/archive/sources/`](docs/archive/sources/README.md),使用者原有未提交的两份旧对接文档也按原字节归档;旧上游 v1 在 [`docs/archive/upstream/`](docs/archive/upstream/README.md) 可离线校验,但不嵌入运行合同。旧 F/W 工作包、旧 MQ-only 合同及归档不作为当前运行入口。固定 MQ `v1`、HTTP `/internal/v1/dispatcher/...` 和业务 revision 是现行通信规则,不是自有实现代次;不得为历史路径新建兼容或回退。 -- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户**。根命令只接受显式 `agent`/`dispatcher`;`mixed` 和裸 `real` 仍拒绝;隔离 `mock`、只读 `sip-only` 和需完整非生产证据/正式获批指令的 `nonprod-real` 为彼此独立的入口。新增非生产真实入口尚未在测试机完成全链路核验或拨号;不得以编译/本机 Mock 通过宣称可用。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,无拨号,不证明线路可用或生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。 +- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户**。根命令只接受显式 `agent`/`dispatcher`;`mixed` 和裸 `real` 仍拒绝;隔离 `mock`、只读 `sip-only` 和需完整非生产证据/正式获批指令的 `nonprod-real` 为彼此独立的入口。新增非生产真实入口尚未在测试机完成全链路核验;2026-10-04 获批的同一数企/号码两次单次试拨操作均未观测到 SIP 包或最终结果,第二次已有 Dispatcher 派发回执而 Agent 错误根因未知。未交付回执保留,Dispatcher/Agent 已停机;不得自动重拨、清理未知执行或以编译/本机 Mock 通过宣称可用。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,至今没有观察到真实 SIP 拨号,不证明线路可用或生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。 - 使用者批准独立的测试专用 `deploys/test/saas-mock/` 仅模拟缺失的 SaaS:从 0600 的静态快照提供五类正式 HTTP 配置,在专用 RabbitMQ vhost 中由模拟 SaaS 预建现行拓扑;不在 Dispatcher 内注入快照;默认不发布外呼消息,只有受限脚本已为同一 `event_id`/线路/原始号码启动抓包后,才可显式单次 MQ 投递;不替代 Agent/Asterisk/AI/OSS。测试用正式入口必须同时具备只读配置、专用 MQ、真实 Agent/Asterisk/AI/录音/OSS、逐通确认和拨号前活跃抓包证据,缺一项不得试拨。此模拟不构成真实 SaaS 签收。 - 开发按 TDD 分批,小步提交;不得覆盖使用者现存修改/未跟踪文件,不自动清理、迁移或覆盖任何现存 SQLite、spool、outbox 和 Agent 恢复文件。旧 `.executions` 及恢复根目录中旧 `.uploads`、`.upload-locks`、逐执行 `state.json` 的发现须只读失败关闭,现存未交付事实由使用者确认处置。真实云账号、EIP、线路、拨号、生产部署和共享数据操作分别需要明确授权。 diff --git a/cmd/sip-go-agent/agent_real.go b/cmd/sip-go-agent/agent_real.go index 784cc5f..041e159 100644 --- a/cmd/sip-go-agent/agent_real.go +++ b/cmd/sip-go-agent/agent_real.go @@ -75,7 +75,7 @@ func newRealAgentServer(ctx context.Context, settings config.AgentEnvironment, d }).Prepare(execution) }, OnFailure: func(execution rpc.ApprovedExecution, cause error) error { - log.Printf("Agent real call requires inspection: event_id=%q task_id=%q cause_type=%T", execution.SourceEventID, execution.TaskID, cause) + log.Printf("Agent real call requires inspection: event_id=%q task_id=%q native_phase=%q ari_http_status=%d cause_type=%T", execution.SourceEventID, execution.TaskID, asterisk.NativeCallPhase(cause), asterisk.NativeCallHTTPStatus(cause), cause) return nil }, } diff --git a/docs/evidence/nonprod-shuqi-attempts-20261004.md b/docs/evidence/nonprod-shuqi-attempts-20261004.md new file mode 100644 index 0000000..6dbd9ca --- /dev/null +++ b/docs/evidence/nonprod-shuqi-attempts-20261004.md @@ -0,0 +1,14 @@ +# 2026-10-04 数企非生产单次操作记录 + +范围:仅 `trunk-shuqi` → 原始号码 `15003164745`;两次均分别获使用者确认,**没有授权自动补拨或其他组合**。证据原件仅保存在测试机受限目录 `/var/lib/sip-go-agent/evidence//`,不复制到仓库。生产签收:`production_approval=false`。 + +| 次数 | 事件号 | 事实 | 结果 | +| --- | --- | --- | --- | +| 1 | `call-0eeb0f6d-2ca9-4789-9b88-509add215c70` | 抓包已启动;私有投递封装器未导出 RabbitMQ 环境变量,MQ 发布前退出;无 SIP 包、无 Dispatcher inbox/outbox、无最终结果。封装器已修复,但没有重试同一事件。 | 失败,保留台账及原始证据。 | +| 2 | `call-669f8829-a111-476d-88c7-3de604bf6dc6` | Dispatcher inbox 已接纳、Agent 接到执行;共享 SaaS 队列收到本次 `call.execute` 的 `dispatched` 派发回执,**不是** `call.execute.result`。旧版 SaaS 等待器将回执误判为最终结果归属不符并提前退出;Agent 同时记录执行失败,旧日志只有 `*errors.joinError`,无法查明底层错误。抓包 0 包,无 SIP INVITE 或最终结果,Asterisk 无活动通道。 | 失败;不得声称已拨通、取得真实 AI/录音/OSS 结果。 | + +第二次抓包文件 SHA-256:`e3f42e2687636327d7f18c9635173252505b4a838fa6829a755bab06c9c69749`(仅 24 字节空 pcap);执行状态文件 SHA-256:`ced1dfa2a7d92313563069504e78156ad106297ccd6676c023a50215d7dceaa9`。Asterisk 日志有 WebSocket Origin/CORS 提示,但后续只读 ARI 连通检查成功,**不能据此认定它是 Agent 失败根因**。 + +现状:专用 Dispatcher、Agent 均已停止;Asterisk 无活动通话;SaaS 结果队列 `agent-call.saas.events.v1` 留存 **1 条派发回执**、无消费者。Dispatcher 的 inbox/outbox 和原始抓包均保留,未清理或伪造最终结果。未交付回执及未知执行的处置须经使用者确认,不因服务重启自动释放占用。 + +本地修改尚未部署到测试机:SaaS 等待器会先持久化并确认归属正确的派发回执,继续等待真正最终结果;Agent 对原生呼叫失败记录阶段及 ARI HTTP 状态码,不记录响应正文、凭据或音频。此修改解决**回执误判**并增强下次定位能力,**尚不能解释第二次 Agent 失败的真实原因**。任何新的真实操作必须使用新事件号、新确认、有效快照和拨号前完整抓包门禁,不复用上述任一事件。 diff --git a/internal/asterisk/call.go b/internal/asterisk/call.go index 7d8337e..4a40d20 100644 --- a/internal/asterisk/call.go +++ b/internal/asterisk/call.go @@ -13,6 +13,7 @@ import ( "git.ipao.vip/rogee/go-sip/internal/media" "github.com/CyCoreSystems/ari/v5" + "github.com/CyCoreSystems/ari/v5/client/native" ) // NativeDial contains only the Dispatcher-selected SIP identity and the @@ -23,6 +24,38 @@ type NativeDial struct { MediaPayloadType uint8 } +// NativeCallFailure records a secret-free stage for a possibly ambiguous ARI +// attempt. The wrapped cause remains available for programmatic inspection. +type NativeCallFailure struct { + Phase string + Cause error +} + +func (e *NativeCallFailure) Error() string { + if errors.Is(e.Cause, context.DeadlineExceeded) { + return fmt.Sprintf("native ARI %s deadline: %T", e.Phase, e.Cause) + } + return fmt.Sprintf("native ARI %s outcome unknown: %T", e.Phase, e.Cause) +} +func (e *NativeCallFailure) Unwrap() error { return e.Cause } +func NativeCallPhase(err error) string { + var failure *NativeCallFailure + if errors.As(err, &failure) { + return failure.Phase + } + return "unclassified" +} + +// NativeCallHTTPStatus extracts a numeric ARI HTTP status without retaining +// a provider response body, credential or request URL in diagnostic logs. +func NativeCallHTTPStatus(err error) int { + var response native.RequestError + if errors.As(err, &response) { + return response.Code() + } + return 0 +} + // NativeCall owns one real ARI channel, bridge, ExternalMedia channel and RTP // socket; closing it cannot originate or replay a second call. type NativeCall struct { @@ -44,11 +77,11 @@ type NativeCall struct { // already persisted the signed instruction and passed the host capture gate. func (l Loader) Originate(ctx context.Context, request NativeDial) (*NativeCall, error) { if _, err := approvedOriginateRequest(request.ExecutionID, request.TrunkID, request.DialedCallee, request.CallerID, request.AnswerTimeout); err != nil { - return nil, err + return nil, &NativeCallFailure{Phase: "validate", Cause: err} } client, err := l.OpenARI() if err != nil { - return nil, err + return nil, &NativeCallFailure{Phase: "ari_open", Cause: err} } return dialWithClient(ctx, client, request) } @@ -66,38 +99,38 @@ func dialWithClient(ctx context.Context, client ari.Client, request NativeDial) call.Context, call.cancel = context.WithCancelCause(ctx) originate, err := approvedOriginateRequest(request.ExecutionID, request.TrunkID, request.DialedCallee, request.CallerID, request.AnswerTimeout) if err != nil { - return nil, err + return nil, &NativeCallFailure{Phase: "validate", Cause: err} } call.Media, err = media.ListenRTPWithFormat("127.0.0.1:0", request.MediaPayloadType, media.FormatSLIN16, 16000) if err != nil { - return nil, fmt.Errorf("prepare Agent RTP capture: %w", err) + return nil, &NativeCallFailure{Phase: "rtp_listen", Cause: err} } key := ari.NewKey(ari.ChannelKey, request.ExecutionID) call.subscription = client.Bus().Subscribe(key, "StasisStart", "StasisEnd", "ChannelHangupRequest", "ChannelDestroyed") if call.subscription == nil { - return nil, errors.New("native ARI channel subscription unavailable before origination") + return nil, &NativeCallFailure{Phase: "subscribe", Cause: errors.New("native ARI channel subscription unavailable before origination")} } call.outbound, err = client.Channel().Originate(key, originate) if err != nil { // The HTTP response can be lost after Asterisk has created the // channel. Do not originate again: try to stop the same identity. call.outbound = client.Channel().Get(key) - return nil, fmt.Errorf("native SIP origination outcome unknown: %T", err) + return nil, &NativeCallFailure{Phase: "originate", Cause: err} } answerCtx, cancel := context.WithTimeout(ctx, request.AnswerTimeout) defer cancel() if err := awaitStasisStart(answerCtx, call.subscription, request.ExecutionID); err != nil { - return nil, err + return nil, &NativeCallFailure{Phase: "answer_wait", Cause: err} } bridgeKey := ari.NewKey(ari.BridgeKey, request.ExecutionID+"-bridge") call.bridge, err = client.Bridge().Create(bridgeKey, "mixing", "go-sip-agent") if err != nil { // A lost reply does not prove that the bridge was not created. call.bridge = client.Bridge().Get(bridgeKey) - return nil, fmt.Errorf("create Agent ARI mixing bridge outcome unknown: %T", err) + return nil, &NativeCallFailure{Phase: "bridge_create", Cause: err} } if err := call.bridge.AddChannel(call.outbound.ID()); err != nil { - return nil, fmt.Errorf("add answered channel to ARI bridge: %T", err) + return nil, &NativeCallFailure{Phase: "bridge_add_outbound", Cause: err} } mediaKey := ari.NewKey(ari.ChannelKey, request.ExecutionID+"-media") call.external, err = client.Channel().ExternalMedia(mediaKey, ari.ExternalMediaOptions{ @@ -108,25 +141,25 @@ func dialWithClient(ctx context.Context, client ari.Client, request NativeDial) // Use the same identity to stop an ExternalMedia channel created // before the HTTP outcome became unknown; never create another. call.external = client.Channel().Get(mediaKey) - return nil, fmt.Errorf("create Agent ARI external media outcome unknown: %T", err) + return nil, &NativeCallFailure{Phase: "external_media_create", Cause: err} } address, err := call.external.GetVariable("UNICASTRTP_LOCAL_ADDRESS") if err != nil { - return nil, fmt.Errorf("read Agent ARI RTP peer address: %T", err) + return nil, &NativeCallFailure{Phase: "rtp_peer_address", Cause: err} } port, err := call.external.GetVariable("UNICASTRTP_LOCAL_PORT") if err != nil { - return nil, fmt.Errorf("read Agent ARI RTP peer port: %T", err) + return nil, &NativeCallFailure{Phase: "rtp_peer_port", Cause: err} } peer, err := netip.ParseAddr(address) if err != nil || !peer.IsLoopback() { - return nil, errors.New("Agent ARI RTP peer is not a local Cell address") + return nil, &NativeCallFailure{Phase: "rtp_peer_validation", Cause: errors.New("Agent ARI RTP peer is not a local Cell address")} } if err := call.Media.SetPeer(net.JoinHostPort(address, port)); err != nil { - return nil, fmt.Errorf("configure Agent RTP peer: %w", err) + return nil, &NativeCallFailure{Phase: "rtp_peer_config", Cause: err} } if err := call.bridge.AddChannel(call.external.ID()); err != nil { - return nil, fmt.Errorf("add Agent RTP channel to ARI bridge: %T", err) + return nil, &NativeCallFailure{Phase: "bridge_add_media", Cause: err} } call.monitorDone = make(chan struct{}) go call.watch(request.ExecutionID) diff --git a/internal/asterisk/call_test.go b/internal/asterisk/call_test.go index 5dd3714..d1c2e87 100644 --- a/internal/asterisk/call_test.go +++ b/internal/asterisk/call_test.go @@ -3,6 +3,7 @@ package asterisk import ( "context" "errors" + "fmt" "net" "strings" "testing" @@ -96,6 +97,21 @@ func (b *testBridges) AddChannel(_ *ari.Key, channelID string) error { } func (b *testBridges) Delete(_ *ari.Key) error { b.deleted++; return nil } +type nativeHTTPStatusError struct{ code int } + +func (e nativeHTTPStatusError) Error() string { return "private ARI response" } +func (e nativeHTTPStatusError) Code() int { return e.code } +func TestNativeCallFailureReportsStageAndHTTPStatusWithoutResponseBody(t *testing.T) { + cause := fmt.Errorf("request failed: %w", nativeHTTPStatusError{code: 404}) + err := errors.Join(&NativeCallFailure{Phase: "originate", Cause: cause}, errors.New("cleanup failed")) + if NativeCallPhase(err) != "originate" || NativeCallHTTPStatus(err) != 404 { + t.Fatalf("must expose classified cause without duplicating call: %v", err) + } + if strings.Contains((&NativeCallFailure{Phase: "originate", Cause: errors.New("private-credential")}).Error(), "private-credential") { + t.Fatal("diagnostic must not leak native ARI response body") + } +} + func TestNativeCallUsesOneARIOriginateAndActualRTPBridge(t *testing.T) { events := make(chan ari.Event, 2) client := &testARIClient{ @@ -164,7 +180,7 @@ func TestNativeCallUnknownOriginationMustAttemptHangupWithoutRetry(t *testing.T) } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || !strings.Contains(err.Error(), "outcome unknown") { + if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || !strings.Contains(err.Error(), "outcome unknown") || NativeCallPhase(err) != "originate" { t.Fatalf("unknown originate outcome must never be reported as a completed call: %v", err) } if client.channels.issued != 1 || len(client.channels.hungup) != 1 || client.channels.hungup[0] != "exec-1" || client.closed != 1 { @@ -186,8 +202,8 @@ func TestNativeCallUnknownBridgeOrMediaCreationCleansKnownIdentities(t *testing. client := &testARIClient{channels: channels, bridges: bridges, bus: &testBus{sub: answerEvents{events: events}}} ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil { - t.Fatal("unknown bridge/media outcome cannot be treated as a live audio session") + if _, err := dialWithClient(ctx, client, NativeDial{ExecutionID: "exec-1", TrunkID: "shuqi", DialedCallee: "708915000000001", CallerID: "BD1234", AnswerTimeout: time.Second, MediaPayloadType: 118}); err == nil || NativeCallPhase(err) != map[string]string{"bridge": "bridge_create", "external-media": "external_media_create"}[step] { + t.Fatalf("unknown bridge/media outcome needs its exact failure phase: %v", err) } wantHungup := 1 if step == "external-media" {