diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index fa645a8..c0db5fc 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -37,6 +37,7 @@ - D 的部署文件读取已独立于旧代次 Schema:Agent 清单仅接受**一个本机端点**;OSS 配置必须匹配本 D 身份、指定本机 HTTPS 目标、15 分钟授权与显式资产上限,凭据只经受控环境变量引用。超大文件、重复/未知字段、多个 Agent、非本机目标、缺失凭据和错误 D 归属均拒绝;JSON 唯一键及凭据引用基础函数已移出旧配置文件。`go test ./internal/config -count=1` 通过。此处仅验证部署数据,不代表 Agent 实际加载、官方 SDK 已签发目标,亦未接入主 `dispatcher` 命令。 - D 的 Agent 会话激活现在必须显式携带规范 UUID v4 的本 D 身份,并写入 Agent 授权绑定;预配置了对应 D 身份的隔离 Agent 会拒绝错误归属,不能再靠 D 自报空身份放行。`go test ./internal/dispatcher -count=1` 通过;主 `dispatcher` 命令尚未使用这条会话链路,不代表本机 D↔A 执行已验收。 +- D 的已授权调用元数据现在只从该 Agent **最新且未过期**的会话产生,每次生成不同操作身份;未注册、会话缺失或过期均明确拒绝。`TestAgentCoordinatorProbesBeforeActivation` 覆盖这些边界,不能代替实际 D 主进程的执行与录音联测。 - Agent 增加独立的无代次环境预检:Agent/Cell 与预授权 D 的身份、会话与私有恢复路径、隔离 Mock 场景和已加载 SIP 测试事实、D gRPC 目标及 mTLS 文件/指纹均须显式提供;仅允许本机监听和本机 D 端点,mixed/real 在访问文件或网络前拒绝。缺失项不继承旧 `FromEnv` 默认值,凭据/地址不回显;`go test ./internal/config -run '^TestLoadAgentEnvironment' -count=1` 通过。此检查已接入根命令可达的唯一 Agent Mock 入口;本机双向 TLS 监听可由受信 D 证书探测,另一张同 CA 证书被指纹门禁拒绝。本地测试用批准的 D 身份激活会话,错误 D UUID 即使携带受信证书也在写入会话日志前拒绝;尚未执行呼叫、验证 Agent→D 录音或真实 Asterisk 加载。 - 会话栅栏修复:Agent 重启后 `session_generation=0` 由持久化代际高水位递增,旧显式代际仍拒绝,代际耗尽明确阻断;D 每次新激活产生独立操作身份,同 boot/epoch 的下一次激活不再误回放旧会话。隔离重启/重复激活测试通过,未作为主 Dispatcher 进程重启或真实部署证据。 diff --git a/internal/dispatcher/agent.go b/internal/dispatcher/agent.go index 58fb61b..a332118 100644 --- a/internal/dispatcher/agent.go +++ b/internal/dispatcher/agent.go @@ -253,6 +253,30 @@ func (c *AgentCoordinator) clientAndSession(agentID string) (agentpb.AgentContro return client, session, nil } +// ApprovedMeta supplies a fresh operation identity from the currently active +// session. Expired or unregistered sessions cannot authorize new work. +func (c *AgentCoordinator) ApprovedMeta(ctx context.Context, agentID string) (*agentpb.RequestMeta, error) { + if c == nil || ctx == nil || agentID == "" { + return nil, errors.New("registered Agent and request context are required") + } + if err := ctx.Err(); err != nil { + return nil, err + } + _, session, err := c.clientAndSession(agentID) + if err != nil { + return nil, err + } + if session.AgentID != agentID || session.CellID == "" || session.BootID == "" || session.DispatcherEpoch == "" || + session.SessionGeneration == 0 || session.ExpiresAtUnixMs <= c.now().UnixMilli() { + return nil, errors.New("approved Agent session is incomplete or expired") + } + identity, err := uuid.NewRandom() + if err != nil { + return nil, fmt.Errorf("create Agent operation identity: %w", err) + } + return c.meta(session, "approved:"+identity.String(), ""), nil +} + // AuthorizeInboundMeta accepts an Agent→Dispatcher fact only from the current // activated Endpoint session. An old boot's durable fact can be replayed, but // its request metadata must use the newly activated boot and generation. diff --git a/internal/dispatcher/agent_test.go b/internal/dispatcher/agent_test.go index ae41db8..7d414ec 100644 --- a/internal/dispatcher/agent_test.go +++ b/internal/dispatcher/agent_test.go @@ -68,6 +68,25 @@ func TestAgentCoordinatorProbesBeforeActivation(t *testing.T) { if first.SessionGeneration != 1 || second.SessionGeneration != 2 || third.SessionGeneration != 3 { t.Fatalf("a new activation replayed an old session: first=%d second=%d third=%d", first.SessionGeneration, second.SessionGeneration, third.SessionGeneration) } + one, err := coordinator.ApprovedMeta(context.Background(), "agent-1") + if err != nil { + t.Fatal(err) + } + two, err := coordinator.ApprovedMeta(context.Background(), "agent-1") + if err != nil { + t.Fatal(err) + } + if one.AgentId != "agent-1" || one.CellId != "cell-1" || one.BootId != status.BootId || + one.DispatcherEpoch != "epoch-2" || one.SessionGeneration != 3 || one.RequestId == two.RequestId || one.OperationId == two.OperationId { + t.Fatal("approved metadata did not bind the latest unique Agent session") + } + if _, err := coordinator.ApprovedMeta(context.Background(), "unknown-agent"); err == nil { + t.Fatal("an unregistered Agent inherited another session") + } + coordinator.now = func() time.Time { return time.Date(2026, 9, 18, 1, 11, 0, 0, time.UTC) } + if _, err := coordinator.ApprovedMeta(context.Background(), "agent-1"); err == nil { + t.Fatal("expired Agent session authorized another operation") + } } func TestAgentCoordinatorActivatesExecutesAndControlsWithoutRetryingOriginate(t *testing.T) {