diff --git a/internal/client/agent/handshake.go b/internal/client/agent/handshake.go index a929192..66c607a 100644 --- a/internal/client/agent/handshake.go +++ b/internal/client/agent/handshake.go @@ -59,3 +59,34 @@ func Handshake(ctx context.Context, transport Transport, hello *rvboxv1.ClientHe } return Session{ID: welcome.GetSessionId(), Generation: welcome.GetSessionGeneration(), Protocol: proto.Clone(payload.GetSelectedProtocol()).(*rvboxv1.ProtocolVersion)}, nil } + +// Reconcile sends the complete retained client state after welcome. The caller +// must apply the returned result durably before accepting fresh dispatch. +func Reconcile(ctx context.Context, transport Transport, session Session, snapshot *rvboxv1.ReconcileSnapshot, limits agentproto.Limits) (*rvboxv1.ReconcileResult, error) { + if transport == nil || session.ID == "" || session.Generation == 0 || snapshot == nil { + return nil, ErrProtocolHandshake + } + request := &rvboxv1.AgentEnvelope{SessionId: session.ID, SessionGeneration: session.Generation, Payload: &rvboxv1.AgentEnvelope_ReconcileSnapshot{ReconcileSnapshot: snapshot}} + if err := agentproto.ValidateEnvelope(request, limits, rvboxv1.Platform_PLATFORM_WINDOWS); err != nil { + return nil, fmt.Errorf("validate reconciliation snapshot: %w", err) + } + encoded, err := proto.Marshal(request) + if err != nil { + return nil, err + } + if err := transport.Write(ctx, encoded); err != nil { + return nil, err + } + received, err := transport.Read(ctx) + if err != nil { + return nil, err + } + result, err := agentproto.DecodeEnvelope(received, limits, rvboxv1.Platform_PLATFORM_WINDOWS) + if err != nil { + return nil, fmt.Errorf("decode ReconcileResult: %w", err) + } + if result.GetSessionId() != session.ID || result.GetSessionGeneration() != session.Generation || result.GetReconcileResult() == nil { + return nil, ErrProtocolHandshake + } + return proto.Clone(result.GetReconcileResult()).(*rvboxv1.ReconcileResult), nil +} diff --git a/internal/client/agent/handshake_test.go b/internal/client/agent/handshake_test.go index 9b27591..9178402 100644 --- a/internal/client/agent/handshake_test.go +++ b/internal/client/agent/handshake_test.go @@ -46,6 +46,24 @@ func TestHandshakeRejectsNonWelcome_BH_SES_07(t *testing.T) { } } +func TestReconcileSnapshot_HP_SES_08(t *testing.T) { + t.Parallel() + result := &rvboxv1.AgentEnvelope{SessionId: "issued-session", SessionGeneration: 7, Payload: &rvboxv1.AgentEnvelope_ReconcileResult{ReconcileResult: &rvboxv1.ReconcileResult{}}} + encoded, err := proto.Marshal(result) + if err != nil { + t.Fatal(err) + } + transport := &fakeTransport{read: encoded} + reconciled, err := Reconcile(context.Background(), transport, Session{ID: "issued-session", Generation: 7}, &rvboxv1.ReconcileSnapshot{}, agentproto.DefaultLimits()) + if err != nil || reconciled == nil { + t.Fatalf("Reconcile = %#v, %v", reconciled, err) + } + var sent rvboxv1.AgentEnvelope + if err := proto.Unmarshal(transport.written, &sent); err != nil || sent.GetReconcileSnapshot() == nil || sent.GetSessionId() != "issued-session" || sent.GetSessionGeneration() != 7 { + t.Fatalf("sent snapshot valid=%t session=%q generation=%d err=%v", sent.GetReconcileSnapshot() != nil, sent.GetSessionId(), sent.GetSessionGeneration(), err) + } +} + type fakeTransport struct { written []byte read []byte diff --git a/internal/server/session/agent_server.go b/internal/server/session/agent_server.go index 31136a9..37b6616 100644 --- a/internal/server/session/agent_server.go +++ b/internal/server/session/agent_server.go @@ -184,6 +184,22 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w server.close(connection, websocket.StatusPolicyViolation, ErrStaleSession.Error()) return } + if snapshot := envelope.GetReconcileSnapshot(); snapshot != nil { + // A retained command requires the command-store reconciliation phase, + // which is deliberately not substituted with a no-op result. + if len(snapshot.GetRetainedCommands()) != 0 { + server.close(connection, websocket.StatusPolicyViolation, "reconciliation for retained commands is unavailable") + return + } + encoded, err := proto.Marshal(&rvboxv1.AgentEnvelope{ + SessionId: encodedSessionID, SessionGeneration: registration.Generation, + Payload: &rvboxv1.AgentEnvelope_ReconcileResult{ReconcileResult: &rvboxv1.ReconcileResult{}}, + }) + if err != nil || queue.EnqueueControl(Frame{Kind: FrameControl, Payload: encoded}) != nil { + server.close(connection, websocket.StatusInternalError, "could not queue reconciliation result") + return + } + } } } diff --git a/test/coverage.toml b/test/coverage.toml index 5f395d5..bc48ac5 100644 --- a/test/coverage.toml +++ b/test/coverage.toml @@ -170,6 +170,12 @@ layer = "unit" status = "implemented" tests = ["internal/client/agent/handshake_test.go:TestHandshakeRejectsNonWelcome_BH_SES_07"] +[[requirements]] +id = "HP-SES-08" +layer = "unit" +status = "implemented" +tests = ["internal/client/agent/handshake_test.go:TestReconcileSnapshot_HP_SES_08"] + [[requirements]] id = "HP-SES-05" layer = "integration" diff --git a/test/integration/clientagent/clientagent_integration_test.go b/test/integration/clientagent/clientagent_integration_test.go index a2ea069..8d91854 100644 --- a/test/integration/clientagent/clientagent_integration_test.go +++ b/test/integration/clientagent/clientagent_integration_test.go @@ -39,4 +39,7 @@ func TestWebSocketHelloWelcome_HP_SES_06(t *testing.T) { if result.ID == "" || result.Generation != 1 || result.Protocol.GetMajor() != 1 { t.Fatalf("handshake result = %#v", result) } + if reconciled, err := agent.Reconcile(ctx, transport, result, &rvboxv1.ReconcileSnapshot{}, agentproto.DefaultLimits()); err != nil || reconciled == nil { + t.Fatalf("empty reconciliation = %#v, %v", reconciled, err) + } }