fix(agent): refuse legacy spool before starting approved recovery

This commit is contained in:
2026-09-30 13:46:48 +08:00
parent 2ca1f7113a
commit 25bf73cc51
3 changed files with 71 additions and 0 deletions
+31
View File
@@ -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) {
@@ -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 {
@@ -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` 均会照常启动;现于创建会话和媒体状态前只读检查这些遗留标记,发现时明确拒绝启动并保留原文件。测试核实没有写新会话、没有修改标记;仅对配置的恢复根目录生效,不替代现存数据的人工核查或处置。
## 验收台账