From 25bf73cc51cbbeb1c9e29445798a83b2ac3e0192 Mon Sep 17 00:00:00 2001 From: Rogee Date: Wed, 30 Sep 2026 13:46:48 +0800 Subject: [PATCH] fix(agent): refuse legacy spool before starting approved recovery --- cmd/sip-go-agent/current_agent_setup.go | 31 +++++++++++++++ cmd/sip-go-agent/current_agent_setup_test.go | 39 +++++++++++++++++++ .../saas-dispatcher-implementation.md | 1 + 3 files changed, 71 insertions(+) diff --git a/cmd/sip-go-agent/current_agent_setup.go b/cmd/sip-go-agent/current_agent_setup.go index b8e96a1..c02c1ba 100644 --- a/cmd/sip-go-agent/current_agent_setup.go +++ b/cmd/sip-go-agent/current_agent_setup.go @@ -6,11 +6,13 @@ import ( "crypto/tls" "crypto/x509" "errors" + "fmt" "log" "maps" "net" "net/http" "os" + "path/filepath" "reflect" "slices" "strings" @@ -50,6 +52,9 @@ func newCurrentAgentServer(ctx context.Context, settings config.AgentEnvironment if err != nil || !root.IsDir() || root.Mode().Perm() != 0700 { return nil, errors.New("current Agent requires an existing private 0700 recovery directory") } + if err := rejectLegacyAgentSpool(settings.RecoveryRoot); err != nil { + return nil, err + } for trunk, revision := range appliedSIP { if strings.TrimSpace(trunk) == "" || revision <= 0 { return nil, errors.New("current Agent requires explicit applied Mock SIP revisions") @@ -116,6 +121,32 @@ func newCurrentAgentServer(ctx context.Context, settings config.AgentEnvironment return handler, nil } +// Old spool files may contain unreported execution or upload outcomes. Never +// start the current Agent on the same root without an explicit disposition. +func rejectLegacyAgentSpool(root string) error { + entries, err := os.ReadDir(root) + if err != nil { + return fmt.Errorf("inspect Agent recovery root: %w", err) + } + for _, entry := range entries { + if entry.Name() == ".uploads" || entry.Name() == ".upload-locks" { + return errors.New("legacy Agent upload spool requires manual disposition") + } + if !entry.IsDir() { + continue + } + info, err := os.Lstat(filepath.Join(root, entry.Name(), "state.json")) + if err == nil { + if !info.IsDir() { + return errors.New("legacy Agent execution spool requires manual disposition") + } + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("inspect Agent recovery root: %w", err) + } + } + return nil +} + // The isolated Mock uploader may reach localhost only, even if a grant or // redirect unexpectedly names a real OSS endpoint. It never logs signed URLs. func localMockHTTPClient(trustPEM []byte) (*http.Client, error) { diff --git a/cmd/sip-go-agent/current_agent_setup_test.go b/cmd/sip-go-agent/current_agent_setup_test.go index f168d5a..9b98f84 100644 --- a/cmd/sip-go-agent/current_agent_setup_test.go +++ b/cmd/sip-go-agent/current_agent_setup_test.go @@ -58,6 +58,45 @@ func TestNewCurrentAgentServerBindsMockCallsToOneSessionAndRecoveryRoot(t *testi } } +func TestNewCurrentAgentServerRefusesLegacySpoolWithoutChangingIt(t *testing.T) { + for _, tc := range []struct { + name string + marker string + dir bool + }{ + {name: "old upload attempts", marker: ".uploads", dir: true}, + {name: "old upload locks", marker: ".upload-locks", dir: true}, + {name: "old execution state", marker: filepath.Join("old-execution", "state.json")}, + } { + t.Run(tc.name, func(t *testing.T) { + settings, scenario := currentAgentSetupFixture(t) + marker := filepath.Join(settings.RecoveryRoot, tc.marker) + if tc.dir { + if err := os.Mkdir(marker, 0700); err != nil { + t.Fatal(err) + } + } else { + if err := os.Mkdir(filepath.Dir(marker), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(marker, []byte(`{"schema_version":"1"}`), 0600); err != nil { + t.Fatal(err) + } + } + server, err := newCurrentAgentServer(context.Background(), settings, scenario, map[string]int64{"trunk-mock": 8}, &isolatedAgentRecordingClient{}) + if err == nil || server != nil { + t.Fatalf("legacy spool was admitted: %v", err) + } + if _, err := os.Lstat(marker); err != nil { + t.Fatalf("legacy spool changed: %v", err) + } + if _, err := os.Lstat(settings.SessionPath); !os.IsNotExist(err) { + t.Fatalf("rejected Agent created session state: %v", err) + } + }) + } +} + func TestNewCurrentAgentServerRefusesUnsafeAdaptersBeforeResources(t *testing.T) { settings, scenario := currentAgentSetupFixture(t) for _, tc := range []struct { diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index bf664f9..6ea5936 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -119,6 +119,7 @@ - Agent 旧业务 RPC 处理器:先以服务结构测试复现旧 `GetBootstrap` 等十个方法仍存在,再删除旧执行、许可、控制、查询、事件和上传处理器及仅依赖旧服务面的专属测试;原混合测试保留会话代际、证书指纹、状态和真实 gRPC 激活。另将旧执行日志写入失败保护迁至现行获批执行测试:日志目录不可写时两次相同请求均不得接受或发起呼叫,修复目录后只发起一次,结果未知时拒绝重拨。`go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、Proto 来源/hash、当前合同及临时 RabbitMQ/HTTPS/双向 TLS 隔离链路均通过;旧执行日志结构、未使用的 Proto 消息与其他引用仍须继续清理,未接触现存业务数据或真实外部服务。 - Agent 旧执行日志根因:新增「首次激活→同一路径重启」测试,复现现行入口曾在初次启动自动写出废弃的 `.executions` 文件,下一次启动又将它识别为旧未交付状态并拒绝服务。移除旧日志写入、回放状态和闲置执行配置,只保留当前 `.approved` 日志、会话代际和旧文件存在时拒绝启动的保护;回归确认首次启动与重启不会产生新旧执行日志。已有 `.executions` 一律保留并失败关闭,不自动清理或猜测其业务内容;当前测试只使用临时目录。 - Agent 旧静态制品入口:现行命令从未提供 `StaticArtifactRaw/Expected`,旧激活分支及只服务于旧 `static-cell-artifact-v0.2` Schema 的手写解析器无法证明 Asterisk 实际加载。结构测试先复现残留,再移除旧 RPC 参数、解析器与专属测试;保留当前隔离 Mock 的 `LoadedSIP` revision 回报及 Dispatcher SIP 版本准入校验。此变更不等于真实 Agent/Asterisk 已加载或管理平台已审批,历史 Schema/来源事实另行辨析。 +- Agent 旧 spool 准入边界:隔离测试先复现现行 Agent 对同一恢复根目录中旧 `.uploads`、`.upload-locks` 与逐执行 `state.json` 均会照常启动;现于创建会话和媒体状态前只读检查这些遗留标记,发现时明确拒绝启动并保留原文件。测试核实没有写新会话、没有修改标记;仅对配置的恢复根目录生效,不替代现存数据的人工核查或处置。 ## 验收台账