diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index ad1d19c..762276f 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -130,6 +130,8 @@ - 旧 Dispatcher 本地配置解析:旧 `LoadDispatcherFile` 独占读取上游 v1 `dispatcher-config.schema.json`,没有现行 Dispatcher CLI 调用者;删除实现与旧文件专属测试。现行入口仍从部署环境独立读取 Dispatcher 身份、SaaS 只读配置端点及受控 OSS 配置,并保留当前校验和错误报告。历史上游 Schema 未改,删除旧入口不代表真实配置来源已联调。 - 本地发布与合同脚本:先运行发布检查复现旧校验器因受保护第三方文档的历史 SHA-256 不匹配而中断,未覆盖用户修改、未改写历史 manifest/hash;改由当前合同正反例验证作准入,单独保留上游 v1 文件 SHA-256 的历史来源核对,删除会重新生成/校验过时代次的两个脚本。发布清单只记录当前 local 合同、MQ 拓扑、Proto 及历史来源 manifest 的实际哈希,不再宣称旧 v0.1/v0.3 草案是现行。发布检查先因旧清单格式失败,改完后仍因旧 CLI 错误文案失败;以当前 `dispatcher --mode mixed` 实测确认其在创建 SQLite 前拒绝,再修正检查文本。最终本地发布及打包、防覆盖、mixed/real 资源前拒绝和当前合同/队列检查均通过;仅生成并删除自有临时产物,未部署、未拨号、未处理现存业务数据。 +- 旧全局环境配置:旧 `Config`/`FromEnv` 同时配置 Mock、mixed/real、旧 Dispatcher SQLite 和 OSS 环境凭据,已无现行业务调用;删除实现和专属测试,保留当前分角色环境及批准文件/受控引用读取。把「旧 OSS 环境变量不可覆盖配置文件与受控凭据」迁至现行 `LoadCurrentOSSConfig` 隔离测试;原旧结构的 mTLS 专属测试删除,现行 Dispatcher/Agent mTLS 校验测试仍在。全仓 Go 测试通过;不更改实际部署配置或现存数据。 + ## 验收台账 P01–P06 的项目内隔离证据见上;P07 全仓命名与唯一入口尚在清理,A01–A12 和 K01–K16 的最终对照仍待 P08。不得用本地 Mock 冒充外部签收。 diff --git a/internal/config/agent_endpoints_test.go b/internal/config/agent_endpoints_test.go index 6f5073c..e993965 100644 --- a/internal/config/agent_endpoints_test.go +++ b/internal/config/agent_endpoints_test.go @@ -52,14 +52,3 @@ func TestParseCertificateFingerprints(t *testing.T) { t.Fatal("expected invalid fingerprint to be rejected") } } - -func TestConfigRequiresDispatcherMTLSForEndpointInventory(t *testing.T) { - cfg := Config{Mode: "mock", DBPath: ":memory:", AgentEndpointsFile: "agents.json"} - if err := cfg.Validate("dispatcher"); err == nil { - t.Fatal("expected Dispatcher mTLS requirement") - } - cfg.MTLSCAFile, cfg.MTLSCertFile, cfg.MTLSKeyFile = "ca.pem", "dispatcher.pem", "dispatcher.key" - if err := cfg.Validate("dispatcher"); err != nil { - t.Fatal(err) - } -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 322b057..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,208 +0,0 @@ -package config - -import ( - "errors" - "fmt" - "os" - "strconv" - "strings" - "time" -) - -type Config struct { - Mode string - DBPath string - SpoolRoot string - RabbitURL string - Exchange string - OutboxBatch int - AgentID string - DispatcherID string - Version string - CellID string - GRPCListen string - DispatcherGRPCListen string - DispatcherGRPCEndpoint string - DispatcherGRPCServerName string - DispatcherGRPCAllowedAgentIDs string - MTLSCAFile string - MTLSCertFile string - MTLSKeyFile string - MTLSServerName string - MTLSPeerCertificateFingerprints string - OSSRegion string - OSSEndpoint string - OSSBucket string - OSSAccessKeyID string - OSSAccessKeySecret string - OSSKeyPrefix string - OSSGrantTTL time.Duration - OSSMaxAssetBytes int64 - StaticArtifactPath string - AgentEndpointsFile string - CallLogPath string - CallLogPhoneKey string - ARIURL string - ARIWebsocketURL string - ARIApplication string - ARIUsername string - ARIPassword string - CallTarget string - CallTrunkID string - CallCallerID string - CallMediaBind string - CallMediaPort int - CallRecordingDirectory string - CallAISnapshotPath string - CallTenantID string - CallTenantKey string - CallTaskID string - CallTaskItemID string - CallExecutionID string -} - -func FromEnv() (Config, error) { - mediaPort, err := envInt("AGENT_CALL_MEDIA_PORT", 12000) - if err != nil { - return Config{}, err - } - return Config{ - Mode: envOr("SIP_GO_AGENT_MODE", "mock"), - DBPath: envOr("DISPATCHER_DB", "dispatcher.db"), - SpoolRoot: envOr("AGENT_SPOOL", "./spool"), - RabbitURL: os.Getenv("RABBITMQ_URL"), - Exchange: envOr("RABBITMQ_EXCHANGE", "agent-call.commands.v1"), - OutboxBatch: 50, - AgentID: envOr("AGENT_ID", "agent-local"), - DispatcherID: envOr("DISPATCHER_ID", "dispatcher-local"), - Version: envOr("AGENT_VERSION", "dev"), - CellID: envOr("CELL_ID", "cell-local"), - GRPCListen: os.Getenv("AGENT_GRPC_LISTEN"), - DispatcherGRPCListen: os.Getenv("DISPATCHER_GRPC_LISTEN"), - DispatcherGRPCEndpoint: os.Getenv("DISPATCHER_GRPC_ENDPOINT"), - DispatcherGRPCServerName: envOr("DISPATCHER_GRPC_SERVER_NAME", os.Getenv("MTLS_SERVER_NAME")), - DispatcherGRPCAllowedAgentIDs: os.Getenv("DISPATCHER_ALLOWED_AGENT_IDS"), - MTLSCAFile: os.Getenv("MTLS_CA_FILE"), - MTLSCertFile: os.Getenv("MTLS_CERT_FILE"), - MTLSKeyFile: os.Getenv("MTLS_KEY_FILE"), - MTLSServerName: os.Getenv("MTLS_SERVER_NAME"), - MTLSPeerCertificateFingerprints: os.Getenv("MTLS_PEER_CERT_FINGERPRINTS"), - OSSMaxAssetBytes: 64 << 20, - StaticArtifactPath: os.Getenv("AGENT_STATIC_ARTIFACT"), - AgentEndpointsFile: os.Getenv("DISPATCHER_AGENT_ENDPOINTS_FILE"), - CallLogPath: os.Getenv("AGENT_CALL_BUSINESS_LOG"), - CallLogPhoneKey: os.Getenv("AGENT_CALL_PHONE_LOG_KEY"), - ARIURL: envOr("ARI_URL", "http://127.0.0.1:8088/ari"), - ARIWebsocketURL: envOr("ARI_WS_URL", "ws://127.0.0.1:8088/ari/events"), - ARIApplication: envOr("ARI_APPLICATION", "agent-call"), - ARIUsername: os.Getenv("ARI_USERNAME"), - ARIPassword: os.Getenv("ARI_PASSWORD"), - CallTarget: os.Getenv("AGENT_CALL_TARGET"), - CallTrunkID: os.Getenv("AGENT_CALL_TRUNK_ID"), - CallCallerID: os.Getenv("AGENT_CALL_CALLER_ID"), - CallMediaBind: envOr("AGENT_CALL_MEDIA_BIND", "127.0.0.1"), - CallMediaPort: mediaPort, - CallRecordingDirectory: envOr("AGENT_CALL_RECORDING_DIR", "./recordings"), - CallAISnapshotPath: os.Getenv("AGENT_CALL_AI_SNAPSHOT"), - CallTenantID: os.Getenv("AGENT_CALL_TENANT_ID"), - CallTenantKey: os.Getenv("AGENT_CALL_TENANT_KEY"), - CallTaskID: os.Getenv("AGENT_CALL_TASK_ID"), - CallTaskItemID: os.Getenv("AGENT_CALL_TASK_ITEM_ID"), - CallExecutionID: os.Getenv("AGENT_CALL_EXECUTION_ID"), - }, nil -} - -func (c Config) Validate(role string) error { - if c.Mode != "mock" && c.Mode != "mixed" && c.Mode != "real" { - return fmt.Errorf("unsupported mode %q", c.Mode) - } - if role == "dispatcher" && strings.TrimSpace(c.DBPath) == "" { - return errors.New("dispatcher DB path is required") - } - if role == "agent" && strings.TrimSpace(c.SpoolRoot) == "" { - return errors.New("agent spool root is required") - } - if role == "agent" && strings.TrimSpace(c.CallLogPath) != "" && strings.TrimSpace(c.CallLogPhoneKey) == "" { - return errors.New("AGENT_CALL_PHONE_LOG_KEY is required when AGENT_CALL_BUSINESS_LOG is set") - } - if role == "agent" && strings.TrimSpace(c.CallLogPhoneKey) != "" && len([]byte(c.CallLogPhoneKey)) < 16 { - return errors.New("AGENT_CALL_PHONE_LOG_KEY must contain at least 16 bytes") - } - if role == "agent" && strings.TrimSpace(c.GRPCListen) != "" { - for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s is required when AGENT_GRPC_LISTEN is enabled", name) - } - } - } - if role == "agent" && c.Mode == "real" && strings.TrimSpace(c.GRPCListen) == "" { - return errors.New("real agent mode requires AGENT_GRPC_LISTEN") - } - if role == "agent" && c.Mode == "real" && strings.TrimSpace(c.StaticArtifactPath) == "" { - return errors.New("real agent mode requires AGENT_STATIC_ARTIFACT") - } - if role == "agent" && strings.TrimSpace(c.MTLSPeerCertificateFingerprints) != "" { - if _, err := ParseCertificateFingerprints(c.MTLSPeerCertificateFingerprints); err != nil { - return fmt.Errorf("invalid MTLS_PEER_CERT_FINGERPRINTS: %w", err) - } - } - if role == "agent" && strings.TrimSpace(c.DispatcherGRPCEndpoint) != "" { - for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s is required when DISPATCHER_GRPC_ENDPOINT is enabled", name) - } - } - } - if role == "dispatcher" && strings.TrimSpace(c.DispatcherGRPCListen) != "" { - for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s is required when DISPATCHER_GRPC_LISTEN is enabled", name) - } - } - for name, value := range map[string]string{"oss.region": c.OSSRegion, "oss.endpoint": c.OSSEndpoint, "oss.bucket": c.OSSBucket, "oss.access_key_id_env reference": c.OSSAccessKeyID, "oss.access_key_secret_env reference": c.OSSAccessKeySecret} { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s must be supplied by the Dispatcher configuration file", name) - } - } - if strings.TrimSpace(c.DispatcherGRPCAllowedAgentIDs) == "" { - return errors.New("DISPATCHER_ALLOWED_AGENT_IDS is required when Dispatcher gRPC is enabled") - } - fingerprints, err := ParseCertificateFingerprints(c.MTLSPeerCertificateFingerprints) - if err != nil { - return fmt.Errorf("invalid MTLS_PEER_CERT_FINGERPRINTS: %w", err) - } - if len(fingerprints) == 0 { - return errors.New("MTLS_PEER_CERT_FINGERPRINTS is required when Dispatcher gRPC is enabled") - } - } - if role == "dispatcher" && strings.TrimSpace(c.AgentEndpointsFile) != "" { - for name, value := range map[string]string{"MTLS_CA_FILE": c.MTLSCAFile, "MTLS_CERT_FILE": c.MTLSCertFile, "MTLS_KEY_FILE": c.MTLSKeyFile} { - if strings.TrimSpace(value) == "" { - return fmt.Errorf("%s is required when DISPATCHER_AGENT_ENDPOINTS_FILE is enabled", name) - } - } - } - if c.Mode == "real" && strings.TrimSpace(c.RabbitURL) == "" && role == "dispatcher" { - return errors.New("real dispatcher mode requires RABBITMQ_URL") - } - return nil -} - -func envOr(key, fallback string) string { - if value := strings.TrimSpace(os.Getenv(key)); value != "" { - return value - } - return fallback -} - -func envInt(key string, fallback int) (int, error) { - value := strings.TrimSpace(os.Getenv(key)) - if value == "" { - return fallback, nil - } - parsed, err := strconv.Atoi(value) - if err != nil { - return 0, fmt.Errorf("invalid %s: %w", key, err) - } - return parsed, nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index dfceb8f..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package config - -import ( - "strings" - "testing" -) - -func TestConfigRejectsRealDispatcherWithoutBroker(t *testing.T) { - c, err := FromEnv() - if err != nil { - t.Fatal(err) - } - c.Mode, c.RabbitURL, c.DBPath = "real", "", ":memory:" - if err := c.Validate("dispatcher"); err == nil { - t.Fatal("expected real-mode broker requirement") - } -} - -func TestFromEnvRejectsInvalidMediaPort(t *testing.T) { - t.Setenv("AGENT_CALL_MEDIA_PORT", "not-a-port") - if _, err := FromEnv(); err == nil || !strings.Contains(err.Error(), "AGENT_CALL_MEDIA_PORT") { - t.Fatalf("expected explicit media port error, got %v", err) - } - t.Setenv("AGENT_CALL_MEDIA_PORT", "43210") - cfg, err := FromEnv() - if err != nil || cfg.CallMediaPort != 43210 { - t.Fatalf("expected configured port, got %d, %v", cfg.CallMediaPort, err) - } - t.Setenv("AGENT_CALL_MEDIA_PORT", "") - cfg, err = FromEnv() - if err != nil || cfg.CallMediaPort != 12000 { - t.Fatalf("expected default port, got %d, %v", cfg.CallMediaPort, err) - } -} - -func TestConfigAcceptsMockAgent(t *testing.T) { - c := Config{Mode: "mock", SpoolRoot: t.TempDir()} - if err := c.Validate("agent"); err != nil { - t.Fatal(err) - } -} - -func TestConfigLoadsUnifiedDispatcherGRPCSettings(t *testing.T) { - t.Setenv("DISPATCHER_GRPC_LISTEN", "127.0.0.1:19443") - t.Setenv("DISPATCHER_GRPC_ENDPOINT", "dispatcher.test:19443") - t.Setenv("DISPATCHER_GRPC_SERVER_NAME", "dispatcher.test") - t.Setenv("DISPATCHER_ALLOWED_AGENT_IDS", "agent-cell-a,agent-cell-b") - c, err := FromEnv() - if err != nil { - t.Fatal(err) - } - if c.DispatcherGRPCListen != "127.0.0.1:19443" || c.DispatcherGRPCEndpoint != "dispatcher.test:19443" || c.DispatcherGRPCServerName != "dispatcher.test" || c.DispatcherGRPCAllowedAgentIDs != "agent-cell-a,agent-cell-b" { - t.Fatalf("unified Dispatcher gRPC settings were not loaded: %+v", c) - } -} - -func TestConfigRequiresPhoneLogPair(t *testing.T) { - c := Config{Mode: "mock", SpoolRoot: t.TempDir(), CallLogPath: "calls.jsonl"} - if err := c.Validate("agent"); err == nil { - t.Fatal("expected phone log key requirement") - } - c.CallLogPhoneKey = "0123456789abcdef" - if err := c.Validate("agent"); err != nil { - t.Fatal(err) - } - c.CallLogPath = "" - if err := c.Validate("agent"); err != nil { - t.Fatalf("key-only configuration should use spool fallback: %v", err) - } - c.CallLogPhoneKey = "short" - if err := c.Validate("agent"); err == nil { - t.Fatal("expected phone log key length requirement") - } -} - -func TestConfigRequiresAgentTLSFilesWhenRPCIsEnabled(t *testing.T) { - c := Config{Mode: "mock", SpoolRoot: t.TempDir(), GRPCListen: "127.0.0.1:19090"} - if err := c.Validate("agent"); err == nil { - t.Fatal("expected mTLS file requirement") - } - c.MTLSCAFile, c.MTLSCertFile, c.MTLSKeyFile = "ca.pem", "agent.pem", "agent.key" - if err := c.Validate("agent"); err != nil { - t.Fatal(err) - } - c.Mode, c.GRPCListen = "real", "" - if err := c.Validate("agent"); err == nil { - t.Fatal("expected real agent listener requirement") - } -} - -func TestConfigRequiresStaticArtifactForRealAgent(t *testing.T) { - c := Config{Mode: "real", SpoolRoot: t.TempDir(), GRPCListen: "127.0.0.1:19090", MTLSCAFile: "ca.pem", MTLSCertFile: "agent.pem", MTLSKeyFile: "agent.key"} - if err := c.Validate("agent"); err == nil { - t.Fatal("expected static artifact requirement") - } - c.StaticArtifactPath = "artifact.json" - if err := c.Validate("agent"); err != nil { - t.Fatal(err) - } -} diff --git a/internal/config/current_oss_test.go b/internal/config/current_oss_test.go index 49eb022..62f0ec3 100644 --- a/internal/config/current_oss_test.go +++ b/internal/config/current_oss_test.go @@ -33,6 +33,21 @@ func TestLoadCurrentOSSConfigRequiresBoundLocalHTTPSAndExplicitCredentials(t *te } } +func TestLoadCurrentOSSConfigIgnoresLegacyOSSEnvironment(t *testing.T) { + for _, key := range []string{"DISPATCHER_OSS_REGION", "DISPATCHER_OSS_ENDPOINT", "DISPATCHER_OSS_BUCKET", "DISPATCHER_OSS_ACCESS_KEY_ID", "DISPATCHER_OSS_ACCESS_KEY_SECRET", "DISPATCHER_OSS_KEY_PREFIX", "DISPATCHER_OSS_GRANT_TTL_SECONDS"} { + t.Setenv(key, "legacy-value") + } + t.Setenv("MOCK_OSS_KEY_ID", "isolated-id") + t.Setenv("MOCK_OSS_KEY_SECRET", "isolated-secret") + configuration, err := LoadCurrentOSSConfig(writeMockOSSDeployment(t, mockOSSDeployment), dispatcherFixtureID) + if err != nil { + t.Fatal(err) + } + if configuration.Endpoint != "https://127.0.0.1:19445" || configuration.Region != "cn-mock" || configuration.Bucket != "mock-bucket" || configuration.KeyPrefix != "approved" || configuration.GrantTTL != 15*time.Minute || configuration.AccessKeyID != "isolated-id" || configuration.AccessKeySecret != "isolated-secret" { + t.Fatal("legacy OSS environment overrode the approved file and credential references") + } +} + func TestLoadCurrentOSSConfigRejectsUnapprovedDeploymentWithoutLeakingCredentials(t *testing.T) { t.Setenv("MOCK_OSS_KEY_ID", "isolated-id") t.Setenv("MOCK_OSS_KEY_SECRET", "isolated-secret") diff --git a/internal/config/oss_file_only_test.go b/internal/config/oss_file_only_test.go deleted file mode 100644 index 772065b..0000000 --- a/internal/config/oss_file_only_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package config - -import "testing" - -func TestLegacyOSSEnvironmentIsNotAConfigurationSource(t *testing.T) { - for _, key := range []string{"DISPATCHER_OSS_REGION", "DISPATCHER_OSS_ENDPOINT", "DISPATCHER_OSS_BUCKET", "DISPATCHER_OSS_ACCESS_KEY_ID", "DISPATCHER_OSS_ACCESS_KEY_SECRET", "DISPATCHER_OSS_KEY_PREFIX"} { - t.Setenv(key, "legacy-value") - } - t.Setenv("DISPATCHER_OSS_GRANT_TTL_SECONDS", "1") - cfg, err := FromEnv() - if err != nil { - t.Fatal(err) - } - if cfg.OSSRegion != "" || cfg.OSSEndpoint != "" || cfg.OSSBucket != "" || cfg.OSSAccessKeyID != "" || cfg.OSSAccessKeySecret != "" || cfg.OSSKeyPrefix != "" || cfg.OSSGrantTTL != 0 { - t.Fatal("legacy environment still supplies OSS settings before the required JSON file") - } -}