From 07850076e35d92dd1939a717ebfa83fb2a8048ef Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 06:59:06 +0800 Subject: [PATCH] Route Agent CLI through isolated approved Mock runtime --- cmd/sip-go-agent/current_agent_command.go | 146 +++++++++++++++ .../current_agent_command_test.go | 168 ++++++++++++++++++ cmd/sip-go-agent/current_tls_test.go | 67 +++++++ cmd/sip-go-agent/main.go | 2 +- cmd/sip-go-agent/main_test.go | 9 +- deploys/env/agent.env.example | 32 ++-- .../saas-dispatcher-implementation.md | 16 +- 7 files changed, 411 insertions(+), 29 deletions(-) create mode 100644 cmd/sip-go-agent/current_agent_command.go create mode 100644 cmd/sip-go-agent/current_agent_command_test.go create mode 100644 cmd/sip-go-agent/current_tls_test.go diff --git a/cmd/sip-go-agent/current_agent_command.go b/cmd/sip-go-agent/current_agent_command.go new file mode 100644 index 0000000..87db6ac --- /dev/null +++ b/cmd/sip-go-agent/current_agent_command.go @@ -0,0 +1,146 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "strings" + + agentpb "git.ipao.vip/rogee/go-sip/gen/agent" + "git.ipao.vip/rogee/go-sip/internal/config" + "git.ipao.vip/rogee/go-sip/internal/rpc" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +func newCurrentAgentCommand() *cobra.Command { + var mode string + command := &cobra.Command{ + Use: "agent", Short: "Run the isolated Agent", Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + settings, err := config.LoadAgentEnvironment(mode) + if err != nil { + return err + } + scenario, err := loadApprovedMockScenario(settings.MockScenarioFile) + if err != nil { + return err + } + applied, err := loadMockAppliedSIP(settings.MockAppliedSIPFile) + if err != nil { + return err + } + ca, err := readAgentPEM("MTLS_CA_FILE", settings.CAFile) + if err != nil { + return err + } + cert, err := readAgentPEM("MTLS_CERT_FILE", settings.CertFile) + if err != nil { + return err + } + key, err := readAgentPEM("MTLS_KEY_FILE", settings.KeyFile) + if err != nil { + return err + } + serverTLS, err := rpc.NewServerTLSConfig(ca, cert, key) + if err != nil { + return errors.New("Agent mTLS listener certificate is invalid") + } + clientTLS, err := rpc.NewClientTLSConfig(ca, cert, key, settings.DispatcherServerName) + if err != nil { + return errors.New("Agent mTLS Dispatcher certificate configuration is invalid") + } + connection, err := grpc.NewClient(settings.DispatcherEndpoint, grpc.WithTransportCredentials(credentials.NewTLS(clientTLS))) + if err != nil { + return errors.New("Agent cannot create pinned Dispatcher connection") + } + defer connection.Close() + handler, err := newCurrentAgentServer(cmd.Context(), settings, scenario, applied, agentpb.NewAgentControlServiceClient(connection)) + if err != nil { + return err + } + listener, err := net.Listen("tcp", settings.Listen) + if err != nil { + return fmt.Errorf("Agent cannot listen on configured Mock address: %w", err) + } + defer listener.Close() + server := grpc.NewServer(grpc.Creds(credentials.NewTLS(serverTLS))) + agentpb.RegisterAgentControlServiceServer(server, handler) + stopped := make(chan struct{}) + go func() { + select { + case <-cmd.Context().Done(): + server.GracefulStop() + case <-stopped: + } + }() + err = server.Serve(listener) + close(stopped) + if err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return fmt.Errorf("Agent gRPC listener stopped: %w", err) + } + if cmd.Context().Err() == nil { + return errors.New("Agent gRPC listener stopped without shutdown") + } + return nil + }, + } + command.Flags().StringVar(&mode, "mode", "mock", "isolated Mock mode only") + return command +} + +// This is an isolated deployment fixture, not SaaS SIP configuration or proof +// that Asterisk actually loaded the revisions. Real/mixed startup is rejected. +func loadMockAppliedSIP(path string) (map[string]int64, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("AGENT_MOCK_APPLIED_SIP_FILE is required") + } + file, err := os.Open(path) + if err != nil { + return nil, errors.New("AGENT_MOCK_APPLIED_SIP_FILE cannot be opened") + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, 64<<10+1)) + if err != nil || len(data) > 64<<10 { + return nil, errors.New("AGENT_MOCK_APPLIED_SIP_FILE exceeds its size limit or cannot be read") + } + var fixture struct { + Trunks map[string]int64 `json:"trunks"` + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&fixture); err != nil { + return nil, errors.New("AGENT_MOCK_APPLIED_SIP_FILE has invalid fields") + } + if err := decoder.Decode(new(any)); err != io.EOF { + return nil, errors.New("AGENT_MOCK_APPLIED_SIP_FILE contains extra data") + } + if len(fixture.Trunks) == 0 { + return nil, errors.New("AGENT_MOCK_APPLIED_SIP_FILE requires explicit trunks") + } + for trunk, revision := range fixture.Trunks { + if strings.TrimSpace(trunk) == "" || revision <= 0 { + return nil, errors.New("AGENT_MOCK_APPLIED_SIP_FILE has invalid trunk revision") + } + } + return fixture.Trunks, nil +} + +func readAgentPEM(name, path string) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("%s cannot be opened", name) + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, 1<<20+1)) + if err != nil || len(data) == 0 || len(data) > 1<<20 { + return nil, fmt.Errorf("%s is unreadable or too large", name) + } + return data, nil +} diff --git a/cmd/sip-go-agent/current_agent_command_test.go b/cmd/sip-go-agent/current_agent_command_test.go new file mode 100644 index 0000000..c65c3d4 --- /dev/null +++ b/cmd/sip-go-agent/current_agent_command_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "io" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentpb "git.ipao.vip/rogee/go-sip/gen/agent" + "git.ipao.vip/rogee/go-sip/internal/rpc" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/status" +) + +func TestCurrentAgentCommandRejectsRealModesAndObsoleteCallEntry(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want string + }{ + {"real", []string{"--mode", "real"}, "Mock"}, + {"mixed", []string{"--mode", "mixed"}, "Mock"}, + {"old call-once", []string{"--call-once"}, "unknown flag"}, + {"no deployment identity", []string{"--mode", "mock"}, "AGENT_ID"}, + } { + t.Run(tc.name, func(t *testing.T) { + state := filepath.Join(t.TempDir(), "session.json") + t.Setenv("AGENT_ID", "") + t.Setenv("AGENT_SESSION_PATH", state) + command := newCurrentAgentCommand() + command.SetOut(io.Discard) + command.SetErr(io.Discard) + command.SetArgs(tc.args) + if err := command.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("unsafe current Agent command was admitted: %v", err) + } + if _, err := os.Stat(state); !os.IsNotExist(err) { + t.Fatalf("rejected command opened durable Agent state: %v", err) + } + }) + } +} + +func TestLoadMockAppliedSIPAcceptsOnlyExplicitLocalFacts(t *testing.T) { + path := filepath.Join(t.TempDir(), "applied-sip.json") + if err := os.WriteFile(path, []byte(`{"trunks":{"trunk-mock":8}}`), 0600); err != nil { + t.Fatal(err) + } + applied, err := loadMockAppliedSIP(path) + if err != nil || len(applied) != 1 || applied["trunk-mock"] != 8 { + t.Fatalf("explicit isolated applied SIP facts were changed: %v %v", applied, err) + } + if _, err := loadMockAppliedSIP(""); err == nil { + t.Fatal("missing applied SIP facts were defaulted") + } + for _, data := range []string{ + `{"trunks":{}}`, `{"trunks":{"trunk-mock":0}}`, `{"trunks":{"":8}}`, + `{"trunks":{"trunk-mock":8},"schema_version":"v3"}`, + `{"trunks":{"trunk-mock":8}} {}`, + } { + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } + if _, err := loadMockAppliedSIP(path); err == nil { + t.Fatalf("unapproved Mock SIP fixture was admitted: %s", data) + } + } +} + +func TestCurrentAgentCommandServesPinnedMutualTLSOnly(t *testing.T) { + ca, agentCert, agentKey, dispatcherCert, dispatcherKey, dispatcherLeaf := localCommandCertificates(t) + settings, _ := currentAgentSetupFixture(t) + for path, body := range map[string][]byte{ + settings.CAFile: ca, settings.CertFile: agentCert, settings.KeyFile: agentKey, + } { + if err := os.WriteFile(path, body, 0600); err != nil { + t.Fatal(err) + } + } + scenarioFile := filepath.Join(settings.RecoveryRoot, "scenario.json") + appliedFile := filepath.Join(settings.RecoveryRoot, "applied-sip.json") + if err := os.WriteFile(scenarioFile, []byte(`{"inbound_pcm16":"AQABAA==","script":{"Turns":[{"Transcript":"synthetic fixture"}]},"max_wav_bytes":4096,"expected_recording":false,"outcome":"no_answer","reason_message":"synthetic no answer"}`), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(appliedFile, []byte(`{"trunks":{"trunk-mock":8}}`), 0600); err != nil { + t.Fatal(err) + } + reserved, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + settings.Listen = reserved.Addr().String() + _ = reserved.Close() + for name, value := range map[string]string{ + "AGENT_ID": settings.AgentID, "CELL_ID": settings.CellID, + "AGENT_GRPC_LISTEN": settings.Listen, "AGENT_SESSION_PATH": settings.SessionPath, + "AGENT_RECOVERY_ROOT": settings.RecoveryRoot, + "DISPATCHER_GRPC_ENDPOINT": settings.DispatcherEndpoint, + "DISPATCHER_GRPC_SERVER_NAME": settings.DispatcherServerName, + "MTLS_CA_FILE": settings.CAFile, "MTLS_CERT_FILE": settings.CertFile, + "MTLS_KEY_FILE": settings.KeyFile, + "MTLS_PEER_CERT_FINGERPRINTS": rpc.CertificateFingerprint(dispatcherLeaf), + "AGENT_MOCK_SCENARIO_FILE": scenarioFile, "AGENT_MOCK_APPLIED_SIP_FILE": appliedFile, + } { + t.Setenv(name, value) + } + process, cancel := context.WithCancel(context.Background()) + command := newCurrentAgentCommand() + command.SetOut(io.Discard) + command.SetErr(io.Discard) + command.SetContext(process) + command.SetArgs([]string{"--mode", "mock"}) + finished := make(chan error, 1) + go func() { finished <- command.Execute() }() + t.Cleanup(func() { + cancel() + select { + case err := <-finished: + if err != nil { + t.Errorf("isolated Agent shutdown failed: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("isolated Agent did not stop after process cancellation") + } + }) + trustedTLS, err := rpc.NewClientTLSConfig(ca, dispatcherCert, dispatcherKey, "agent.local") + if err != nil { + t.Fatal(err) + } + dial, stopDial := context.WithTimeout(context.Background(), 6*time.Second) + defer stopDial() + trusted, err := grpc.DialContext(dial, settings.Listen, grpc.WithTransportCredentials(credentials.NewTLS(trustedTLS)), grpc.WithBlock()) + if err != nil { + select { + case startupErr := <-finished: + t.Fatalf("Agent startup failed: %v (dial: %v)", startupErr, err) + default: + t.Fatalf("isolated Agent mTLS listener unavailable: %v", err) + } + } + defer trusted.Close() + probe := &agentpb.GetAgentStatusRequest{ + Meta: &agentpb.RequestMeta{ProtocolVersion: "agent.v1", RequestId: "mock-status", TraceId: "mock-status", OperationId: "mock-status", AgentId: settings.AgentID, CellId: settings.CellID}, + Target: &agentpb.AgentBinding{AgentId: settings.AgentID, CellId: settings.CellID}, + } + response, err := agentpb.NewAgentControlServiceClient(trusted).GetAgentStatus(dial, probe) + if err != nil || response.GetStatus().GetAgentId() != settings.AgentID || response.GetStatus().GetCellId() != settings.CellID { + t.Fatalf("trusted D could not probe the actual Agent command: response=%v err=%v", response, err) + } + untrustedTLS, err := rpc.NewClientTLSConfig(ca, agentCert, agentKey, "agent.local") + if err != nil { + t.Fatal(err) + } + untrusted, err := grpc.NewClient(settings.Listen, grpc.WithTransportCredentials(credentials.NewTLS(untrustedTLS))) + if err != nil { + t.Fatal(err) + } + defer untrusted.Close() + if _, err := agentpb.NewAgentControlServiceClient(untrusted).GetAgentStatus(dial, probe); status.Code(err) != codes.PermissionDenied { + t.Fatalf("unpinned but CA-valid client could probe Agent: %v", err) + } +} diff --git a/cmd/sip-go-agent/current_tls_test.go b/cmd/sip-go-agent/current_tls_test.go new file mode 100644 index 0000000..fcd5438 --- /dev/null +++ b/cmd/sip-go-agent/current_tls_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "testing" + "time" +) + +// localCommandCertificates creates isolated fixtures for both command-line +// roles. No certificate or key is stored outside the test's temporary files. +func localCommandCertificates(t *testing.T) (ca, agentCert, agentKey, dispatcherCert, dispatcherKey []byte, dispatcherLeaf *x509.Certificate) { + t.Helper() + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + root := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "isolated test root"}, + NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + rootDER, err := x509.CreateCertificate(rand.Reader, root, root, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + root, err = x509.ParseCertificate(rootDER) + if err != nil { + t.Fatal(err) + } + ca = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER}) + issue := func(serial int64, dns string) ([]byte, []byte, *x509.Certificate) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: dns}, + NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), DNSNames: []string{dns}, + BasicConstraintsValid: true, KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, root, &key.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + parsed, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + privateDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateDER}), parsed + } + agentCert, agentKey, _ = issue(2, "agent.local") + dispatcherCert, dispatcherKey, dispatcherLeaf = issue(3, "dispatcher.local") + return +} diff --git a/cmd/sip-go-agent/main.go b/cmd/sip-go-agent/main.go index e5d9c12..d68e888 100644 --- a/cmd/sip-go-agent/main.go +++ b/cmd/sip-go-agent/main.go @@ -52,7 +52,7 @@ func newRootCommand() *cobra.Command { SilenceUsage: true, SilenceErrors: true, } - root.AddCommand(newAgentCommand(), newDispatcherCommand()) + root.AddCommand(newCurrentAgentCommand(), newDispatcherCommand()) return root } diff --git a/cmd/sip-go-agent/main_test.go b/cmd/sip-go-agent/main_test.go index 34eb968..52217a3 100644 --- a/cmd/sip-go-agent/main_test.go +++ b/cmd/sip-go-agent/main_test.go @@ -19,12 +19,11 @@ func TestRootHasExplicitRoles(t *testing.T) { } } -func TestAgentRejectsInvalidMediaPortEnvironment(t *testing.T) { - t.Setenv("AGENT_CALL_MEDIA_PORT", "invalid") +func TestRootAgentRejectsOldOneShotFlag(t *testing.T) { root := newRootCommand() - root.SetArgs([]string{"agent"}) - if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "AGENT_CALL_MEDIA_PORT") { - t.Fatalf("expected invalid port error, got %v", err) + root.SetArgs([]string{"agent", "--call-once"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("obsolete one-shot entry was admitted: %v", err) } } diff --git a/deploys/env/agent.env.example b/deploys/env/agent.env.example index ce6f7b7..2651523 100644 --- a/deploys/env/agent.env.example +++ b/deploys/env/agent.env.example @@ -1,18 +1,20 @@ -# Production physical-host example. Inject secrets and approved paths out of band. -SIP_GO_AGENT_MODE=real -AGENT_ID=agent-cell-a -AGENT_VERSION=0.1.0-p1.20260919 -CELL_ID=cell-a -AGENT_SPOOL=/var/lib/sip-go-agent/agent/spool -AGENT_GRPC_LISTEN=0.0.0.0:19090 +# Isolated local Mock only. This is not a production or real-call configuration. +# A non-production validation host still requires the mandatory native Asterisk +# and capture-first diagnostics in deploys/test/nonprod-call-evidence.sh. +# Start explicitly: sip-go-agent agent --mode mock +AGENT_ID=agent-mock +CELL_ID=cell-mock +AGENT_GRPC_LISTEN=127.0.0.1:19090 +AGENT_SESSION_PATH=/var/lib/sip-go-agent/agent/session.json +# Pre-create this directory with mode 0700; neither state nor files are cleared. +AGENT_RECOVERY_ROOT=/var/lib/sip-go-agent/agent/recovery +DISPATCHER_GRPC_ENDPOINT=127.0.0.1:19443 +DISPATCHER_GRPC_SERVER_NAME=dispatcher.local MTLS_CA_FILE=/etc/sip-go-agent/pki/ca.pem MTLS_CERT_FILE=/etc/sip-go-agent/pki/agent.pem MTLS_KEY_FILE=/etc/sip-go-agent/pki/agent.key -MTLS_SERVER_NAME=agent-cell-a.internal -# Approved Dispatcher management address; upload data goes Agent -> OSS directly. -# DISPATCHER_GRPC_ENDPOINT=:19443 -# DISPATCHER_GRPC_SERVER_NAME=dispatcher.internal -DISPATCHER_AGENT_ENDPOINTS_FILE=/etc/sip-go-agent/agent-endpoints.json -AGENT_STATIC_ARTIFACT=/etc/sip-go-agent/artifacts/cell-a.json -AGENT_CALL_BUSINESS_LOG=/var/lib/sip-go-agent/agent/call-business.jsonl -# AGENT_CALL_PHONE_LOG_KEY= +# Required: SHA-256 fingerprint of the approved Dispatcher's certificate. +# MTLS_PEER_CERT_FINGERPRINTS= +# Synthetic fixtures only; applied SIP revisions do not prove Asterisk loaded them. +AGENT_MOCK_SCENARIO_FILE=/etc/sip-go-agent/mock/scenario.json +AGENT_MOCK_APPLIED_SIP_FILE=/etc/sip-go-agent/mock/applied-sip.json diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index 70ebf57..889e407 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -34,7 +34,7 @@ - 新增无实现代次字段的 Dispatcher 环境预检:必须显式给出规范 UUID v4 身份、只读 HTTP 地址及密钥、RabbitMQ 地址和 SQLite 路径;当前 Mock 仅接受本机 HTTP/MQ 目标,mixed/real 在任何资源操作前拒绝。预检不打开数据库或网络,缺失值不继承旧默认配置,错误不打印凭据。`go test ./internal/config -run '^TestLoadDispatcherEnvironment' -count=1` 通过;此预检尚未接入主 CLI,不能当作 P02/P03 完成。 -- Agent 增加独立的无代次环境预检:单 Agent/Cell 身份、会话与私有恢复路径、隔离 Mock 场景和已加载 SIP 测试事实、D gRPC 目标及 mTLS 文件/指纹均须显式提供;仅允许本机监听和本机 D 端点,mixed/real 在访问文件或网络前拒绝。缺失项不继承旧 `FromEnv` 默认值,凭据/地址不回显;`go test ./internal/config -run '^TestLoadAgentEnvironment' -count=1` 通过。此检查尚未接入主 `agent` 命令,也不能证明实际 TLS 或 Asterisk 加载。 +- Agent 增加独立的无代次环境预检:单 Agent/Cell 身份、会话与私有恢复路径、隔离 Mock 场景和已加载 SIP 测试事实、D gRPC 目标及 mTLS 文件/指纹均须显式提供;仅允许本机监听和本机 D 端点,mixed/real 在访问文件或网络前拒绝。缺失项不继承旧 `FromEnv` 默认值,凭据/地址不回显;`go test ./internal/config -run '^TestLoadAgentEnvironment' -count=1` 通过。此检查已接入根命令可达的唯一 Agent Mock 入口;本机双向 TLS 监听可由受信 D 证书探测,另一张同 CA 证书被指纹门禁拒绝。该测试尚未激活 D 会话、执行呼叫、验证 Agent→D 录音或真实 Asterisk 加载。 ## P03:HTTP 读取分批改造(未整体签收) @@ -59,11 +59,11 @@ - 对话控制隔离验证:共享 `callflow` 不再根据转写或回复内的硬编码词推断拒联,仅处理明确的关键词/拒联事实;ASR-only 不播报开场或 TTS 回复。配置的首语音/静默期限、整通话期限与最大轮数交给媒体控制器;回复按 Unicode 字符数分片,火山 TTS 的缓存音频块数超限明确失败而不重试;不支持的插话配置在准入前拒绝。该控制器尚未接入新主 CLI,不能宣称真实通话媒体已验证。 - Agent 隔离媒体入口 `rpc.RunApprovedCall` 仅接收签发的执行快照、媒体会话、挂断动作和显式每通话 AI pipeline;缺失或 typed nil 适配器在读媒体前拒绝,无隐式 SDK/Mock 回退。测试分别由已绑定快照构建 SDK pipeline 与注入受控合成脚本的 `ApprovedMockPipeline`。签发通话期限与 AI 总期限均约束整通电话;ASR-only 不合成开场,full-AI 开场完成才采集语音。父级期限不会被“未检测到语音”掩盖,空媒体 Mock 等到上下文真正结束。当前媒体只处理 16 kHz PCM16,SDK 虽接受、但媒体不能正确处理的 24 kHz ASR/TTS 配置在 Dispatcher 与 Agent 绑定时明确拒绝,不静默播放或识别错速音频。该入口尚未连接主 CLI,也未用于真实拨号。 - 隔离 Mock AI 适配器仅消费显式冻结的模拟媒体与最终识别脚本,不手写或替代供应商协议;缺失/多余轮次、未批准的开场或助理音频明确拒绝。ASR-only 不执行开场与回复;full-AI 开场只允许一次,用户最终识别的已配置字面关键词先挂断、绝不播放该轮候选回复,未配置关键词不推断拒联。任务未配置最大轮数时严格使用共享通话流程的一轮边界,负数仍拒绝。Mock 输出不代表真实 ASR/LLM/TTS 已通过。 -- 新增 Agent 任务级在途通话栅栏 `TaskCalls`:按 D/数字租户/任务隔离;暂停或停止先拒新通话,hangup 请求取消、drain 不挂断,两者均等待已登记通话结束才确认;等待超时仍保留关闭栅栏,停止后不能恢复,同一控制重复执行不另设去重。隔离测试验证两任务互不干扰、重复结束不破坏状态。该栅栏现由严格 Agent Mock 组装将已授权执行的**合成**在途通话登记到同一个控制器;主 CLI 仍未使用实际媒体入口,Dispatcher 的持久控制仍为权威,不能据此称真实通话已受控。 -- 新增内部任务级 `ApplyApprovedTaskControl` Proto、生成物及 Dispatcher `ApprovedOriginator.SendControl`:D 每次发送前从已激活会话领取元数据,只生成独立 RPC 追踪 ID,不从 SaaS 控制虚构 command_id、幂等键或 revision;Agent 校验活跃 D 会话、数字租户、任务和 pause/stop 的 hangup/drain 政策,在 Mock 中等待已登记活动通话结束后才确认,超时保留本地停止栅栏。隔离测试覆盖拒绝伪 D/旧会话、非法字段与政策、无适配器、未知 RPC 结果不自动重试及重复控制的显式重送;本地双向 TLS gRPC 还验证了受信 D 证书经真实生成 Stub 传送控制后,先挂断再等已登记通话结束才确认,不受信的客户端证书与旧会话均拒绝且不能重新准入。**仅隔离合成 runner 已经登记,实际媒体与新主 CLI 尚未接线**,不能据此称真实通话控制已验收。 -- `ApprovedCallWorker` 与 `NewApprovedAgentServer` 强制把已授权执行和控制 RPC 绑定同一个任务栅栏、显式 Mock 模式及 Agent 进程期限;每通话先在无拨号/无业务写入前准备并核验签发 AI、合成媒体和交付目标,准备失败即明确拒绝且不发完成事实;签发拨号时限在异步工人发出接受回执前重验,超时或任务关闭同样在无拨号时明确拒绝;启动请求结束不取消在途通话,任务 hangup 等待 runner 明确结束后才完成控制。隔离测试覆盖先执行回执后合成挂断、进程关闭、失败观察、重复身份防重及缺失/竞争适配器拒绝;未知执行不自动重拨。这里只证明调度与取消顺序,**未在主 CLI 连接真实媒体、录音、失败报告或出站 MQ**。 -- CLI 增加隔离 Mock 场景读取器:只接受显式给出的有界 JSON 文件,合成 PCM16、脚本、录音预期、结果事实和 WAV 上限均须逐项提供;缺失/未知字段、额外 JSON、非法 base64 和超大文件明确拒绝,错误不回显脚本内容。此读取器尚未连接 `agent` 主命令,不能把场景配置或 Mock 结果称为 SaaS 智能体值或真实音频验收。 -- Agent 组装辅助 `newCurrentAgentServer` 已把显式 Mock 部署身份、已加载 SIP 隔离事实、每通话合成脚本、同一个控制/执行栅栏以及 Agent→D 的会话元数据/录音交付绑定;拒绝缺失或 typed-nil 的 D 客户端、非法 SIP revision、未私有化的恢复目录和竞争配置,构造时不打开会话状态文件。共用的 Mock 上传 HTTP 客户端只连接本机且不跟随跳往外网的重定向,脚本、已加载事实与指纹均复制为启动快照。相关隔离/反例与 race 测试通过;**还未由实际 `agent` 命令调用,也未在此入口建立 mTLS 监听与已鉴权的 D 连接**,不算主运行链路通过。 +- 新增 Agent 任务级在途通话栅栏 `TaskCalls`:按 D/数字租户/任务隔离;暂停或停止先拒新通话,hangup 请求取消、drain 不挂断,两者均等待已登记通话结束才确认;等待超时仍保留关闭栅栏,停止后不能恢复,同一控制重复执行不另设去重。隔离测试验证两任务互不干扰、重复结束不破坏状态。该栅栏已由根命令的隔离合成 runner 绑定到同一个控制器;尚未进行主进程执行+控制的完整联测,真实媒体入口未启用,Dispatcher 的持久控制仍为权威,不能据此称真实通话已受控。 +- 新增内部任务级 `ApplyApprovedTaskControl` Proto、生成物及 Dispatcher `ApprovedOriginator.SendControl`:D 每次发送前从已激活会话领取元数据,只生成独立 RPC 追踪 ID,不从 SaaS 控制虚构 command_id、幂等键或 revision;Agent 校验活跃 D 会话、数字租户、任务和 pause/stop 的 hangup/drain 政策,在 Mock 中等待已登记活动通话结束后才确认,超时保留本地停止栅栏。隔离测试覆盖拒绝伪 D/旧会话、非法字段与政策、无适配器、未知 RPC 结果不自动重试及重复控制的显式重送;本地双向 TLS gRPC 还验证了受信 D 证书经真实生成 Stub 传送控制后,先挂断再等已登记通话结束才确认,不受信的客户端证书与旧会话均拒绝且不能重新准入。**隔离合成 runner 已接入新主 CLI,主进程 D 控制联测及真实媒体仍未验证**,不能据此称真实通话控制已验收。 +- `ApprovedCallWorker` 与 `NewApprovedAgentServer` 强制把已授权执行和控制 RPC 绑定同一个任务栅栏、显式 Mock 模式及 Agent 进程期限;每通话先在无拨号/无业务写入前准备并核验签发 AI、合成媒体和交付目标,准备失败即明确拒绝且不发完成事实;签发拨号时限在异步工人发出接受回执前重验,超时或任务关闭同样在无拨号时明确拒绝;启动请求结束不取消在途通话,任务 hangup 等待 runner 明确结束后才完成控制。隔离测试覆盖先执行回执后合成挂断、进程关闭、失败观察、重复身份防重及缺失/竞争适配器拒绝;未知执行不自动重拨。这里证明隔离组件的调度与取消顺序;新 Agent 主 CLI 已组装合成 runner、录音交付与失败观察,但**尚未完成主进程 D 会话/录音交付、真实媒体或出站 MQ 的端到端联测**。 +- Agent 主命令现使用严格有界的隔离 Mock 场景及已加载 SIP 测试事实读取器:合成 PCM16、脚本、录音预期、结果事实、WAV 上限及 SIP revision 均须显式给出;缺失/未知字段、额外 JSON、非法 base64 或不合理 revision 拒绝,错误不回显场景内容。隔离加载事实不是 Asterisk 加载证据,也不是 SaaS 智能体值或真实音频验收。 +- 根命令已将 `newCurrentAgentServer` 接入唯一可达的 `agent` 子命令:显式 Mock 身份、每通话合成脚本、同一控制/执行栅栏及 Agent→D 会话元数据/录音交付已组装;缺失/typed-nil D 客户端、不私有的恢复目录及竞争配置拒绝。共用 Mock 上传客户端仅能连接本机且拒绝外部跳转;脚本、SIP 加载事实和证书指纹复制为启动快照。隔离命令测试实际启动本机双向 TLS gRPC 监听,受信 D 证书读取 Agent 状态,同 CA 未列入指纹的证书被拒绝;`--call-once` 已不再由根命令接受。**尚未在主进程验证 Agent→D 的真实会话激活/录音执行或部署诊断,更非真实 Asterisk、线路和供应商通过。** - 验证:`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`、`go test ./... -count=1`、`go test -race ./internal/ai ./internal/callflow ./internal/configread ./internal/dispatcher ./internal/rpc -count=1`、`go vet ./...`、`go build ./...`、`git diff --check` 均通过。 - **后续边界:** 录音直传/OSS 失败恢复及最终结果属 P06;新主 CLI 接线、真实媒体完整联动及旧路径清理属 P07;隔离端到端和 A01–A12 属 P08。真实 Agent/Asterisk、SaaS、MQ、AI 供应商联调未开展,不能由 Mock 结果代签。 @@ -80,8 +80,8 @@ - Agent 录音交付隔离组件:`RecordingDelivery` 先确认结束,再依照录音是否实际生成分别上报唯一空录音结果或请求原授权并直传内存 WAV;录音生成失败保留通话真实结果、空录音对象及明确原因,不虚构上传事实。隔离测试通过本地 HTTP PUT 和假 Dispatcher RPC 覆盖成功无业务文件、OSS 明确失败后私有文件保存、恢复写入失败、未知 PUT 隔离、重启重领原目标、上传已确认后只重发原结果。再次调用不会隐式重新 PUT;正常已确认上传但尚未被 D 持久收讫的跨进程间隙仍受 K16 边界约束。此处未连接主入口批准执行媒体或 MQ;下述隔离链路另测本地真正的 D gRPC/SQLite。 - 最终结果隔离组件:`ApprovedExecution` 已保留获批任务的 `caller_profile_id`;`FinalResultPayload` 只从批准的任务身份、确认时间及实际收到的用户媒体窗口生成当前唯一结果;最终 ASR 文本与字面关键词拒联写入完整转写,未播放的助手候选内容不冒充转写,未知 SIP 响应码保留 `null`,无录音保留 `{}`。单元测试直接核验当前 MQ Schema、字段缺失/乱序时窗与无应答负例;开场白发送失败不得计入已播放音频。此组件尚未接入主入口,也未证明真实 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 尚未接到主 CLI 的 `ApprovedCallWorker`,测试 OSS/RPC 均为隔离替身,不能代表真实线路、供应商或 MQ 投递。 -- 已验证:`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 交付组件的主入口接线、主入口 Agent↔Dispatcher 实际会话与录音执行接线(隔离 mTLS gRPC 已测)、真实执行时上传事实/最终结果交付及 MQ/端到端验收,不能宣称 P06 通过。 +- `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 测试均为隔离替身,不能代表真实线路或供应商通过。 +- 已验证:`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 通过。 ## 验收台账