feat: persist and dispatch client command input

This commit is contained in:
2026-09-06 10:40:23 +00:00
parent 8155e5f81f
commit ad564de23f
16 changed files with 836 additions and 22 deletions
+36
View File
@@ -227,6 +227,24 @@ layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/windows/selection_test.go:TestSelectExecutionContextBoundaries_BH_WINCTX_02"]
[[requirements]]
id = "HP-WINENV-01"
layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/windows/environment_test.go:TestEnvironmentOverlayIsCaseInsensitiveAndDeterministic_HP_WINENV_01"]
[[requirements]]
id = "BH-WINENV-01"
layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/windows/environment_test.go:TestEnvironmentOverlayRejectsMalformedAndDuplicateBase_BH_WINENV_01"]
[[requirements]]
id = "BH-SUPERVISOR-01"
layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/supervisor_test.go:TestStartSpecValidation_BH_SUPERVISOR_01"]
[[requirements]]
id = "BH-SES-01"
layer = "unit"
@@ -302,6 +320,24 @@ layer = "integration"
status = "implemented"
tests = ["test/integration/clientagent/clientagent_integration_test.go:TestControlQueueWakesReconciledSession_HP_DISPATCH_06"]
[[requirements]]
id = "HP-DISPATCH-07"
layer = "unit"
status = "implemented"
tests = ["internal/client/spool/spool_test.go:TestExecutionSpecIsDurableAndQuotaCounted_HP_DISPATCH_07"]
[[requirements]]
id = "BH-DISPATCH-07"
layer = "unit"
status = "implemented"
tests = ["internal/client/spool/spool_test.go:TestExecutionSpecCorruptionMarksSpoolDirty_BH_DISPATCH_07"]
[[requirements]]
id = "HP-DISPATCH-08"
layer = "integration"
status = "implemented"
tests = ["test/integration/clientagent/clientagent_integration_test.go:TestControlStdinIntentReplaysAndAcknowledges_HP_DISPATCH_08"]
[[requirements]]
id = "HP-EVENT-01"
layer = "unit"
@@ -193,6 +193,115 @@ func TestControlQueueWakesReconciledSession_HP_DISPATCH_06(t *testing.T) {
}
}
func TestControlStdinIntentReplaysAndAcknowledges_HP_DISPATCH_08(t *testing.T) {
ctx := context.Background()
persistence, err := store.Open(ctx, store.Options{DataDir: filepath.Join(t.TempDir(), "state"), BusyTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
defer persistence.Close()
registry := session.NewRegistry()
server := httptest.NewServer(&session.AgentServer{Store: persistence, Registry: registry, Path: "/v1/agent", Limits: agentproto.DefaultLimits()})
defer server.Close()
controlService, err := control.NewService(control.Options{Store: persistence, WakeClient: registry.Wake, CursorKey: []byte("0123456789abcdef0123456789abcdef")})
if err != nil {
t.Fatal(err)
}
address := "ws" + strings.TrimPrefix(server.URL, "http") + "/v1/agent"
transport, err := agent.DialWebSocket(ctx, address, nil)
if err != nil {
t.Fatal(err)
}
defer transport.Close()
hello := &rvboxv1.ClientHello{ClientId: "win-stdin-client", SupportedProtocol: &rvboxv1.ProtocolRange{Major: 1, MinMinor: 0, MaxMinor: 0}, DaemonVersion: "test", Platform: rvboxv1.Platform_PLATFORM_WINDOWS, Architecture: "amd64", DaemonCwd: `C:\`, SupportedShells: []rvboxv1.ShellType{rvboxv1.ShellType_SHELL_POWERSHELL}, ClientInstanceId: "019c46f1-1d02-7000-8000-000000000079", MaxRunningCommands: 1, MaxQueuedCommands: 1, SentAt: timestamppb.Now()}
accepted, err := agent.Handshake(ctx, transport, hello, agentproto.DefaultLimits())
if err != nil {
t.Fatal(err)
}
issue := fixedIssueID(0x7a)
spec := &rvboxv1.ExecutionSpec{ShellType: rvboxv1.ShellType_SHELL_POWERSHELL, Source: &rvboxv1.ExecutionSpec_CommandText{CommandText: "Read-Host"}}
serialized, err := proto.Marshal(spec)
if err != nil {
t.Fatal(err)
}
hash := sha256.Sum256([]byte("stdin request"))
parsedIssue, _ := domain.ParseUUIDv7(issue)
if _, err := persistence.QueueCommand(ctx, store.QueueCommandInput{IssueUUID: parsedIssue, ClientID: hello.GetClientId(), IssueTime: time.Now().UTC(), ReceiptTime: time.Now().UTC(), ImmutableSHA256: hash, ExecutionSpec: serialized}); err != nil {
t.Fatal(err)
}
if _, err := agent.Reconcile(ctx, transport, accepted, &rvboxv1.ReconcileSnapshot{}, agentproto.DefaultLimits()); err != nil {
t.Fatal(err)
}
readContext, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
encoded, err := transport.Read(readContext)
if err != nil {
t.Fatal(err)
}
dispatch, err := agentproto.DecodeEnvelope(encoded, agentproto.DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS)
if err != nil || dispatch.GetCommandDispatch() == nil || dispatch.GetCommandDispatch().GetIssueUuid() != issue {
t.Fatalf("initial dispatch = %#v, %v", dispatch, err)
}
acceptedEnvelope, err := proto.Marshal(&rvboxv1.AgentEnvelope{SessionId: accepted.ID, SessionGeneration: accepted.Generation, Payload: &rvboxv1.AgentEnvelope_CommandAccepted{CommandAccepted: &rvboxv1.CommandAccepted{IssueUuid: issue, CommandRevision: 1, Accepted: true}}})
if err != nil {
t.Fatal(err)
}
if err := transport.Write(ctx, acceptedEnvelope); err != nil {
t.Fatal(err)
}
stdinID := fixedIssueID(0x7b)
if _, err := controlService.AppendStdin(ctx, &rvboxv1.AppendStdinRequest{ClientId: hello.GetClientId(), IssueUuid: issue, RequestId: stdinID, Data: []byte("input"), AppendNewline: true}); err != nil {
t.Fatal(err)
}
if intents, pendingErr := persistence.PendingStdin(ctx, hello.GetClientId()); pendingErr != nil {
t.Fatalf("pending stdin query = %v", pendingErr)
} else if len(intents) != 1 || string(intents[0].Data) != "input" {
t.Fatalf("pending stdin intents = %#v", intents)
}
encoded, err = transport.Read(readContext)
if err != nil {
t.Fatal(err)
}
stdinEnvelope, err := agentproto.DecodeEnvelope(encoded, agentproto.DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS)
if err != nil || stdinEnvelope.GetStdinWrite() == nil || stdinEnvelope.GetStdinWrite().GetWriteSeq() != 1 || string(stdinEnvelope.GetStdinWrite().GetData()) != "input" || !stdinEnvelope.GetStdinWrite().GetAppendNewline() {
t.Fatalf("stdin delivery = %#v, %v", stdinEnvelope, err)
}
ackEvent := &rvboxv1.CommandEvent{IssueUuid: issue, EventSeq: 1, ObservedAt: timestamppb.Now(), Payload: &rvboxv1.CommandEvent_StdinAck{StdinAck: &rvboxv1.StdinAcknowledgement{WriteSeq: 1}}}
if err := agent.SendCommandEvent(ctx, transport, accepted, ackEvent, agentproto.DefaultLimits()); err != nil {
t.Fatal(err)
}
if _, err := transport.Read(readContext); err != nil {
t.Fatal(err)
}
var acknowledged int
if err := persistence.DB().QueryRow(`SELECT acknowledged FROM stdin_writes WHERE issue_uuid = ? AND write_seq = 1`, parsedIssue[:]).Scan(&acknowledged); err != nil || acknowledged != 1 {
t.Fatalf("stdin acknowledgement = %d, %v", acknowledged, err)
}
closeID := fixedIssueID(0x7c)
if _, err := controlService.CloseStdin(ctx, &rvboxv1.CloseStdinRequest{ClientId: hello.GetClientId(), IssueUuid: issue, RequestId: closeID}); err != nil {
t.Fatal(err)
}
encoded, err = transport.Read(readContext)
if err != nil {
t.Fatal(err)
}
closeEnvelope, err := agentproto.DecodeEnvelope(encoded, agentproto.DefaultLimits(), rvboxv1.Platform_PLATFORM_WINDOWS)
if err != nil || closeEnvelope.GetCloseStdin() == nil || closeEnvelope.GetCloseStdin().GetWriteSeq() != 2 {
t.Fatalf("stdin close delivery = %#v, %v", closeEnvelope, err)
}
closeAck := &rvboxv1.CommandEvent{IssueUuid: issue, EventSeq: 2, ObservedAt: timestamppb.Now(), Payload: &rvboxv1.CommandEvent_StdinAck{StdinAck: &rvboxv1.StdinAcknowledgement{WriteSeq: 2, StdinClosed: true}}}
if err := agent.SendCommandEvent(ctx, transport, accepted, closeAck, agentproto.DefaultLimits()); err != nil {
t.Fatal(err)
}
if _, err := transport.Read(readContext); err != nil {
t.Fatal(err)
}
var closeAcknowledged int
if err := persistence.DB().QueryRow(`SELECT acknowledged FROM stdin_writes WHERE issue_uuid = ? AND write_seq = 2`, parsedIssue[:]).Scan(&closeAcknowledged); err != nil || closeAcknowledged != 1 {
t.Fatalf("stdin close acknowledgement = %d, %v", closeAcknowledged, err)
}
}
func fixedIssueID(last byte) string {
issue, _ := domain.ParseUUIDv7("019c46f1-1d02-7000-8000-000000000001")
issue[15] = last