Verify isolated recorded call through Dispatcher and Agent

This commit is contained in:
2026-09-30 09:37:22 +08:00
parent 67cb438e2e
commit db4678754d
2 changed files with 178 additions and 19 deletions
@@ -4,16 +4,21 @@ package main
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@@ -89,7 +94,33 @@ func TestCurrentDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T)
}
root := t.TempDir()
reserved, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listen := reserved.Addr().String()
_ = reserved.Close()
ca, agentCert, agentKey, dispatcherCert, dispatcherKey, dispatcherLeaf := localCommandCertificates(t)
var putCount, putBytes atomic.Int64
var putChecksum atomic.Value
oss := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || !strings.HasPrefix(r.URL.Path, "/mock-bucket/approved/") {
http.Error(w, "unexpected Mock OSS request", http.StatusBadRequest)
return
}
checksum := sha256.New()
size, err := io.Copy(checksum, r.Body)
if err != nil || size < 44 {
http.Error(w, "invalid Mock WAV upload", http.StatusBadRequest)
return
}
putBytes.Store(size)
putChecksum.Store(hex.EncodeToString(checksum.Sum(nil)))
putCount.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer oss.Close()
ca = append(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: oss.Certificate().Raw})...)
agentBlock, _ := pem.Decode(agentCert)
if agentBlock == nil {
t.Fatal("isolated Agent certificate is invalid")
@@ -102,12 +133,42 @@ func TestCurrentDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T)
if err != nil {
t.Fatal(err)
}
agentServer := rpc.NewServer(rpc.ServerOptions{
Mode: "mock", StatePath: filepath.Join(root, "agent-session.json"), ApprovedDispatcherID: dispatcherID,
RequirePeerCertificate: true, PeerCertificateFingerprints: map[string]struct{}{rpc.CertificateFingerprint(dispatcherLeaf): {}},
Status: &agentpb.AgentStatus{AgentId: "agent-mock", CellId: "cell-mock", BootId: "boot-mock", ProtocolVersion: "agent.v1"},
LoadedSIP: func(context.Context) (map[string]int64, error) { return map[string]int64{"trunk-mock": 8}, nil },
})
for filename, content := range map[string][]byte{
filepath.Join(root, "ca.pem"): ca,
filepath.Join(root, "agent.pem"): agentCert,
filepath.Join(root, "agent.key"): agentKey,
} {
if err := os.WriteFile(filename, content, 0600); err != nil {
t.Fatal(err)
}
}
settings, scenario := currentAgentSetupFixture(t)
settings.DispatcherID = dispatcherID
settings.DispatcherEndpoint = listen
settings.CAFile = filepath.Join(root, "ca.pem")
settings.CertFile = filepath.Join(root, "agent.pem")
settings.KeyFile = filepath.Join(root, "agent.key")
settings.SessionPath = filepath.Join(root, "agent-session.json")
settings.RecoveryRoot = filepath.Join(root, "agent-recovery")
settings.PeerFingerprints = map[string]struct{}{rpc.CertificateFingerprint(dispatcherLeaf): {}}
if err := os.MkdirAll(settings.RecoveryRoot, 0700); err != nil {
t.Fatal(err)
}
dispatcherTLS, err := rpc.NewClientTLSConfig(ca, agentCert, agentKey, settings.DispatcherServerName)
if err != nil {
t.Fatal(err)
}
agentConnection, err := grpc.NewClient(listen, grpc.WithTransportCredentials(credentials.NewTLS(dispatcherTLS)))
if err != nil {
t.Fatal(err)
}
defer agentConnection.Close()
agentContext, stopAgent := context.WithCancel(context.Background())
defer stopAgent()
agentServer, err := newCurrentAgentServer(agentContext, settings, scenario, map[string]int64{"trunk-mock": 8}, agentpb.NewAgentControlServiceClient(agentConnection))
if err != nil {
t.Fatal(err)
}
agentListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
@@ -125,6 +186,33 @@ func TestCurrentDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T)
}
examples[name] = body
}
weekly := map[string]any{}
for _, day := range []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} {
weekly[day] = []map[string]string{{"start": "00:00", "end": "24:00"}}
}
var sipConfig map[string]any
if err := json.Unmarshal(examples["config-read-sip.json"], &sipConfig); err != nil {
t.Fatal(err)
}
trunk := sipConfig["trunks"].([]any)[0].(map[string]any)
trunk["transport"] = "udp"
trunk["auth_mode"] = "ip"
trunk["registration_required"] = false
trunk["max_concurrent_calls"] = 3
trunk["schedule"].(map[string]any)["weekly_windows"] = weekly
if examples["config-read-sip.json"], err = json.Marshal(sipConfig); err != nil {
t.Fatal(err)
}
var taskConfig map[string]any
if err := json.Unmarshal(examples["config-read-task-asr.json"], &taskConfig); err != nil {
t.Fatal(err)
}
taskSchedule := taskConfig["schedule"].(map[string]any)
taskSchedule["starts_at"] = "2000-01-01T00:00:00+08:00"
taskSchedule["weekly_windows"] = weekly
if examples["config-read-task-asr.json"], err = json.Marshal(taskConfig); err != nil {
t.Fatal(err)
}
var sipReads, discoveryReads, taskReads, providerReads, quotaReads atomic.Int64
saas := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-DISPATCHER-id") != dispatcherID || r.Header.Get("X-DISPATCHER-SECRET-KEY") != "isolated-secret" {
@@ -165,7 +253,7 @@ func TestCurrentDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T)
ossFile := filepath.Join(root, "oss.json")
for filename, body := range map[string][]byte{
agentInventory: []byte(fmt.Sprintf(`[{"agent_id":"agent-mock","cell_id":"cell-mock","address":%q,"server_name":"agent.local"}]`, agentListener.Addr().String())),
ossFile: []byte(fmt.Sprintf(`{"dispatcher_id":%q,"oss":{"endpoint":"https://127.0.0.1:19445","region":"cn-mock","bucket":"mock-bucket","object_prefix":"approved","access_key_id_env":"MOCK_OSS_KEY_ID","access_key_secret_env":"MOCK_OSS_KEY_SECRET","max_asset_bytes":1048576}}`, dispatcherID)),
ossFile: []byte(fmt.Sprintf(`{"dispatcher_id":%q,"oss":{"endpoint":%q,"region":"cn-mock","bucket":"mock-bucket","object_prefix":"approved","access_key_id_env":"MOCK_OSS_KEY_ID","access_key_secret_env":"MOCK_OSS_KEY_SECRET","max_asset_bytes":1048576}}`, dispatcherID, oss.URL)),
filepath.Join(root, "ca.pem"): ca,
filepath.Join(root, "dispatcher.pem"): dispatcherCert,
filepath.Join(root, "dispatcher.key"): dispatcherKey,
@@ -174,12 +262,6 @@ func TestCurrentDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T)
t.Fatal(err)
}
}
reserved, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
listen := reserved.Addr().String()
_ = reserved.Close()
database := filepath.Join(root, "dispatcher.sqlite")
for name, value := range map[string]string{
"DISPATCHER_ID": dispatcherID, "DISPATCHER_SECRET_KEY": "isolated-secret",
@@ -243,6 +325,82 @@ func TestCurrentDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T)
t.Fatalf("isolated Dispatcher stopped despite a live process: %v", err)
default:
}
commandBody, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", "mq-execute.json"))
if err != nil {
cancel()
t.Fatal(err)
}
if err := admin.PublishWithContext(process, taskRoute.Exchange, taskRoute.BindingKey, true, false, amqp.Publishing{ContentType: "application/json", DeliveryMode: amqp.Persistent, Body: commandBody}); err != nil {
cancel()
t.Fatal(err)
}
resultDeadline := time.After(12 * time.Second)
for {
message, ok, err := admin.Get(result.Queue, false)
if err != nil {
cancel()
t.Fatalf("cannot read isolated result queue: %v", err)
}
if ok {
var event struct {
EventType string `json:"event_type"`
Payload struct {
Outcome string `json:"outcome"`
Recording struct {
Status string `json:"status"`
Bucket string `json:"bucket"`
Format string `json:"format"`
SizeBytes int64 `json:"size_bytes"`
ChecksumSHA256 string `json:"checksum_sha256"`
} `json:"recording"`
} `json:"payload"`
}
if err := json.Unmarshal(message.Body, &event); err != nil {
cancel()
t.Fatalf("isolated result is invalid JSON: %v", err)
}
if err := message.Ack(false); err != nil {
cancel()
t.Fatalf("isolated result cannot be ACKed: %v", err)
}
if event.EventType == "call.execute.result" {
if event.Payload.Outcome != "answered" || event.Payload.Recording.Status != "uploaded" || event.Payload.Recording.Bucket != "mock-bucket" {
cancel()
t.Fatalf("isolated call did not produce an uploaded result: outcome=%q recording=%q bucket=%q", event.Payload.Outcome, event.Payload.Recording.Status, event.Payload.Recording.Bucket)
}
if event.Payload.Recording.Format != "wav" || event.Payload.Recording.SizeBytes != putBytes.Load() || event.Payload.Recording.ChecksumSHA256 != putChecksum.Load().(string) {
cancel()
t.Fatalf("isolated result WAV metadata does not match the single Mock PUT: size=%d format=%q", event.Payload.Recording.SizeBytes, event.Payload.Recording.Format)
}
break
}
}
select {
case err := <-finished:
cancel()
t.Fatalf("isolated Dispatcher exited before the final result: %v", err)
case <-resultDeadline:
cancel()
t.Fatal("isolated call did not produce a final result")
case <-time.After(25 * time.Millisecond):
}
}
if putCount.Load() != 1 {
cancel()
t.Fatalf("isolated call uploaded %d times; expected exactly one PUT", putCount.Load())
}
if err := filepath.WalkDir(settings.RecoveryRoot, func(_ string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !entry.IsDir() {
return fmt.Errorf("successful upload persisted recovery file %s", entry.Name())
}
return nil
}); err != nil {
cancel()
t.Fatal(err)
}
cancel()
select {
case err := <-finished:
@@ -80,17 +80,18 @@
- 已新增 Agent 内存录音单次 PUT 组件 `UploadClient.UploadBytes`:复用受限授权、大小和 SHA-256 核对,成功路径不写临时录音或通话结果文件。OSS 明确拒绝保留状态码且不自动重试;传输结果不明时返回专用错误、停止自动重试并隐藏带签名的 URL。空授权头拒绝而非使服务崩溃。这里只验证组件,尚未连接实际录音或最终结果。
- 内部 OSS 授权增加 Dispatcher 原始 bucket,官方 SDK 签发时返回获批 bucket;Agent 收到缺少 bucket 的授权会在 PUT 前拒绝,失败恢复的显式重申请若返回了不同 bucket,也在再次 PUT 前拒绝。两条红灯测试证明先前会错误上传;修复后全包测试及 Agent/RPC/OSS race 测试通过。这里只核验本地授权载体,不代表新主入口已完成签发或真实 OSS 已验证。
- Agent 失败恢复隔离组件:仅在 OSS PUT 明确失败后,把原 bucket/object_key 对应的录音和通话信息两文件写入私有目录并同步落盘;双文件缺失或损坏明确报错、不伪造结果。完成保存后固定 48 小时窗口,按 1 分钟递增至最长 1 小时重试;到期保留原文件。PUT 前持久写入 in-flight,结果不明或进程重启不会二次 PUT;确认上传后先持久记录成功,再经注入的 Mock 回报通话结果,回报失败/重启仅重发原结果。启动扫描识别遗漏文件并提供不暴露原路径的稳定摘要;主程序录音授权与回报已接线,但尚未通过含任务的端到端联测。
- Dispatcher 的无录音最终结果隔离组件:`CurrentStore.RecordCallResult` 仅在确认通话结束后,按持久任务快照校验任务、被叫、主叫和已选线路,并以源执行事件固定生成唯一最终结果身份;消息通过严格 MQ Schema 校验后与 outbox 在同一事务写入。同内容重投/重启只恢复原消息,冲突结果和 SQLite 写入失败均不会产生第二份结果。这里只验证隔离组件,Agent 实际回报尚未连通。
- Dispatcher 的无录音最终结果隔离组件:`CurrentStore.RecordCallResult` 仅在确认通话结束后,按持久任务快照校验任务、被叫、主叫和已选线路,并以源执行事件固定生成唯一最终结果身份;消息通过严格 MQ Schema 校验后与 outbox 在同一事务写入。同内容重投/重启只恢复原消息,冲突结果和 SQLite 写入失败均不会产生第二份结果。此条事务测试为隔离组件;下述根命令隔离联测已连通批准的 Mock Agent 回报,但不代表外部应用收讫。
- Dispatcher 的原始 OSS 目标及已上传结果隔离组件:新 SQLite 布局把一次通话的 upload_id、bucket、object_key、录音格式/时长/大小和 SHA-256 唯一绑定到已保留的执行;不保存临时 URL 或 TOKEN。旧布局拒绝启动并原样保留待交付 outbox,不自动迁移或清理。已签发录音目标不能通过空录音结果绕过上传;已有空录音最终结果不能再签发录音目标。`RecordUploadedCallResult` 仅接受与持久绑定完全一致的录音事实及 Agent 所报告的成功 PUT 状态,录音确认与唯一最终结果 outbox 同事务提交;丢失回报或 MQ 投递时重用原消息,已确认后拒绝再次签发 PUT 授权。Mock 证明的是本地状态约束,不是独立 OSS 校验或真实 Agent 身份验证。
- Dispatcher 的终结与外呼回执顺序竞争隔离修复:原流程在 Agent 接受执行的 RPC 返回后才写入外呼回执,快速结束或 RPC 超时可能先到;现以一次 SQLite 事务在确认通话已结束后补齐原回执并释放占用,未知执行仅在确认结束后释放。迟到的执行响应、超时和重复结束不会产生第二份回执;注入 outbox 写入失败保留原占用。并发竞争及结束后立即生成唯一最终结果有单元测试;主入口 Agent 会话注入已接线,实际通话执行与结果交付尚未联测。
- Dispatcher 录音事实 Unary RPC 隔离服务:`RequestRecordingUpload`、`ReportCallEnded`、`ReportCallResult` 均要求已配置本 D、核验 mTLS 指纹及当前 Agent 会话、数字租户和已保留的执行;复用官方 SDK 仅对原始录音签发固定 15 分钟授权,显式重申请仍用相同 bucket/object_key。结束事实可先于外呼响应而持久化原回执;录音结果核对 D 已存目标和 Agent 报告的成功 PUT,再与唯一结果 outbox 同事务提交。Mock 覆盖会话/租户拒绝、同资产重申请、上传前结果拒绝、坏 JSON、已上传与无录音结果及重复回报。隔离测试还通过本地双向 TLS 的 gRPC 实际传输:Agent `RecordingClient` 每次读取并克隆当前会话元数据,经受控 D 客户端领取授权、上报结束和唯一结果;未配置客户端明确拒绝。其分层测试不包含 OSS PUT;下述本地隔离链路另验证直传,主入口批准执行的合成媒体录音已接线,但完整录音交付尚未在主进程联测。
- Dispatcher 的终结与外呼回执顺序竞争隔离修复:原流程在 Agent 接受执行的 RPC 返回后才写入外呼回执,快速结束或 RPC 超时可能先到;现以一次 SQLite 事务在确认通话已结束后补齐原回执并释放占用,未知执行仅在确认结束后释放。迟到的执行响应、超时和重复结束不会产生第二份回执;注入 outbox 写入失败保留原占用。并发竞争及结束后立即生成唯一最终结果有单元测试;根 Dispatcher 的 Agent 会话、合成 Mock 呼叫、上传事实和 MQ 最终结果现已在本机隔离联测;MQ 失败后的根命令重启恢复仍待验证。
- Dispatcher 录音事实 Unary RPC 隔离服务:`RequestRecordingUpload`、`ReportCallEnded`、`ReportCallResult` 均要求已配置本 D、核验 mTLS 指纹及当前 Agent 会话、数字租户和已保留的执行;复用官方 SDK 仅对原始录音签发固定 15 分钟授权,显式重申请仍用相同 bucket/object_key。结束事实可先于外呼响应而持久化原回执;录音结果核对 D 已存目标和 Agent 报告的成功 PUT,再与唯一结果 outbox 同事务提交。Mock 覆盖会话/租户拒绝、同资产重申请、上传前结果拒绝、坏 JSON、已上传与无录音结果及重复回报。隔离测试还通过本地双向 TLS 的 gRPC 实际传输:Agent `RecordingClient` 每次读取并克隆当前会话元数据,经受控 D 客户端领取授权、上报结束和唯一结果;未配置客户端明确拒绝。其分层测试不包含 OSS PUT;下述本地隔离链路另验证直传,根 Dispatcher 与批准的 Mock Agent 服务已在本机双向 TLS 链路完成合成媒体录音、录音 Unary、单次 OSS PUT 和共享 MQ 最终结果;真实 Agent CLI 与外部服务未在该测试中启动。
- 内存录音隔离组件:`RecordingSession` 仅复制共享通话流程实际读到和成功发送的 16-kHz PCM16,`EncodeMonoWAV` 直接在内存生成有界单声道 WAV;空音频、奇数字节、超过上限及未成功发送的音频都不能伪造成可上传录音。单元与 race 测试未产生业务文件。批准执行的合成 Mock runner 已接入该组件;Mock 中观测到的帧不等于真实 Asterisk 通话的全量媒体验收。
- Agent 录音交付隔离组件:`RecordingDelivery` 先确认结束,再依照录音是否实际生成分别上报唯一空录音结果或请求原授权并直传内存 WAV;录音生成失败保留通话真实结果、空录音对象及明确原因,不虚构上传事实。隔离测试通过本地 HTTP PUT 和假 Dispatcher RPC 覆盖成功无业务文件、OSS 明确失败后私有文件保存、恢复写入失败、未知 PUT 隔离、重启重领原目标、上传已确认后只重发原结果。再次调用不会隐式重新 PUT;正常已确认上传但尚未被 D 持久收讫的跨进程间隙仍受 K16 边界约束。此处未连接主入口批准执行媒体或 MQ;下述隔离链路另测本地真正的 D gRPC/SQLite。
- Agent 录音交付隔离组件:`RecordingDelivery` 先确认结束,再依照录音是否实际生成分别上报唯一空录音结果或请求原授权并直传内存 WAV;录音生成失败保留通话真实结果、空录音对象及明确原因,不虚构上传事实。隔离测试通过本地 HTTP PUT 和假 Dispatcher RPC 覆盖成功无业务文件、OSS 明确失败后私有文件保存、恢复写入失败、未知 PUT 隔离、重启重领原目标、上传已确认后只重发原结果。再次调用不会隐式重新 PUT;正常已确认上传但尚未被 D 持久收讫的跨进程间隙仍受 K16 边界约束。此条失败/恢复测试是分层证据;根 Dispatcher 通过临时 RabbitMQ、批准 Agent 服务与本地 HTTPS OSS 的正向联测另见下述记录。
- 最终结果隔离组件:`ApprovedExecution` 已保留获批任务的 `caller_profile_id`;`FinalResultPayload` 只从批准的任务身份、确认时间及实际收到的用户媒体窗口生成当前唯一结果;最终 ASR 文本与字面关键词拒联写入完整转写,未播放的助手候选内容不冒充转写,未知 SIP 响应码保留 `null`,无录音保留 `{}`。单元测试直接核验当前 MQ Schema、字段缺失/乱序时窗与无应答负例;开场白发送失败不得计入已播放音频。此组件已接入 Agent 合成 runner,但未证明真实 RTP 转写时间精度。
- 本地隔离链路:`RunApprovedCall` 显式使用合成 `ApprovedMockPipeline`,让共享批准通话流程按 ASR-only 限制消费实际读出的 PCM Mock 媒体帧;`RecordingSession` 生成内存 WAV,`FinalResultPayload` 仅用最终模拟识别及实测采集时窗构造结果。Agent 经双向 TLS gRPC 向 Dispatcher 确认结束、领取原始资产签名授权,再对本地 HTTPS OSS Mock 单次 PUT;Dispatcher 将原上传事实与唯一最终结果 outbox 同事务提交。测试确认一次 PUT、一次结果、无业务文件,使用官方 SDK 生成的路径;Mock ASR 与 OSS 服务不验证真实供应商协议或签名。这不是主入口执行、RabbitMQ 投递或外部验收。
- `ApprovedRecordedMockCall` 将显式批准的 ASR-only AI 快照、合成 PCM 媒体、每通话独立脚本、实际采集的有界 WAV、用户最终识别时窗和 `RecordingDelivery` 组合到一个可供 Agent 工人调用的 Mock runner;隔离测试验证先结束事实、一次本地 OSS PUT、唯一最终结果及正常路径无业务文件。无实际读入媒体时明确报告失败并以空录音、生成失败原因收口,不伪造上传或转写;缺失每通话交付器、交付器与签发 D/数字租户/事件不一致、私有恢复目录并非 `0700` 或模拟脚本不满足已批准 AI 时,均在发外呼接受回执前拒绝。此 runner 已接到隔离 Mock 主 CLI 的 `ApprovedCallWorker`,但还没有从主进程完成 D→A 执行、Agent→D 录音与 MQ 出站的端到端联测;现有 OSS/RPC 测试均为隔离替身,不能代表真实线路或供应商通过。
- `ApprovedRecordedMockCall` 将显式批准的 ASR-only AI 快照、合成 PCM 媒体、每通话独立脚本、实际采集的有界 WAV、用户最终识别时窗和 `RecordingDelivery` 组合到一个可供 Agent 工人调用的 Mock runner;隔离测试验证先结束事实、一次本地 OSS PUT、唯一最终结果及正常路径无业务文件。无实际读入媒体时明确报告失败并以空录音、生成失败原因收口,不伪造上传或转写;缺失每通话交付器、交付器与签发 D/数字租户/事件不一致、私有恢复目录并非 `0700` 或模拟脚本不满足已批准 AI 时,均在发外呼接受回执前拒绝。此 runner 已接到隔离 Mock 主 CLI 的 `ApprovedCallWorker`,该合成 runner 已在根 Dispatcher 驱动的本机隔离流中完成 D→A 执行、Agent→D 录音、HTTPS OSS 单次 PUT 和 MQ 出站;这不代表真实线路或供应商通过。
- 根 Dispatcher 正向隔离链路:`bash scripts/check-current-mq-mock.sh` 启用临时 RabbitMQ、五类本机 Mock HTTP 资源和预建任务队列;根 Dispatcher 激活实际批准的 Mock Agent 服务并处理一条 `call.execute`。Agent 以合成 16-kHz PCM 生成 WAV,经双向 TLS 领取授权后单次 PUT 至本机 HTTPS OSS;共享结果队列收到 `answered`/`uploaded` 的唯一最终事实,WAV 字节数和 SHA-256 与 PUT 一致,正常路径私有恢复目录没有业务文件。Agent 使用与根命令相同的服务组装函数,但该测试未启动 Agent CLI;MQ 确认不是 SaaS 应用收讫,真实外部及失败重启仍需分别验证。
- 本机 HTTPS Mock 信任:Agent 上传及 Dispatcher 配置读取使用现有 mTLS CA 信任束校验证书,不放宽主机名或证书验证,仍拒绝非本机目标及跨域重定向。测试确认已批准的本机 HTTPS 证书可访问,未知证书与无效信任束均失败;未使用 `InsecureSkipVerify`。这不是对真实 OSS 的验证。
- 已验证:`go test ./... -count=1`、`go test -race ./... -count=1`、`go vet ./...`、`go build ./...`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-current-contracts.sh`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-proto.sh`、`git diff --check`。主 Agent 入口已组装隔离合成媒体和录音交付,但尚未通过主进程 D↔A 会话/执行/录音、实际上传事实与最终结果交付、MQ 发布及重启恢复的整体联测,不能宣称 P06 通过。
- 已验证:`go test ./... -count=1`、`go test -race ./... -count=1`、`go vet ./...`、`go build ./...`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-current-contracts.sh`、`PATH=/tmp/sip-go-agent-tools/bin:$PATH bash scripts/check-proto.sh`、`git diff --check`。根 Dispatcher 的本机 Mock 正向执行、录音与共享 MQ 结果已通过;主入口 MQ 失败及重启恢复尚未整体联测,不能据此宣称 P06 完成或真实外部验收通过。
## 验收台账