feat: complete Windows client control and recovery paths

This commit is contained in:
2026-09-06 13:37:32 +00:00
parent 56b15c7f4f
commit 486894557d
38 changed files with 2188 additions and 106 deletions
+71 -1
View File
@@ -12,10 +12,13 @@ import (
"os"
"os/signal"
"syscall"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/agentproto"
"github.com/rvbox/rvbox/internal/config"
"github.com/rvbox/rvbox/internal/domain"
"github.com/rvbox/rvbox/internal/observability"
"github.com/rvbox/rvbox/internal/server/control"
"github.com/rvbox/rvbox/internal/server/session"
"github.com/rvbox/rvbox/internal/server/store"
@@ -24,12 +27,26 @@ import (
func main() {
var configPath string
var checkConfig bool
flag.StringVar(&configPath, "config", "", "absolute server TOML configuration path")
flag.BoolVar(&checkConfig, "check-config", false, "validate server configuration and exit")
flag.Parse()
if configPath == "" {
log.Print("rvbox-server: --config is required")
os.Exit(2)
}
if checkConfig {
configured, err := config.LoadServer(configPath)
if err != nil {
log.Printf("rvbox-server: invalid configuration: %v", err)
os.Exit(1)
}
if _, err := fmt.Fprintf(os.Stdout, "valid server configuration: data_dir=%s agent_listen=%s control_socket=%s observability_listen=%s json_rpc_enabled=%t\n", configured.Server.DataDir, configured.Server.AgentListen, configured.Server.ControlSocket, configured.Observability.Listen, configured.JSONRPC.Enabled); err != nil {
log.Printf("rvbox-server: write check result: %v", err)
os.Exit(1)
}
return
}
if err := run(configPath); err != nil {
log.Printf("rvbox-server: %v", err)
os.Exit(1)
@@ -58,6 +75,43 @@ func run(configPath string) error {
return err
}
defer persistence.Close()
health := observability.New()
healthServer := &http.Server{Handler: health.Handler(observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}), ReadHeaderTimeout: configured.Flow.WriteDeadline}
// Health is deliberately best effort. A port collision or a temporary
// listener failure must not prevent the control/agent service from starting;
// operators still get the failure in the daemon log.
var healthListener net.Listener
if configured.Observability.Listen != "" {
healthListener, err = net.Listen("tcp", configured.Observability.Listen)
if err != nil {
log.Printf("rvbox-server: observability endpoint unavailable (continuing without it): %v", err)
}
}
if healthListener != nil {
defer healthListener.Close()
}
recoveryContext, cancelRecovery := context.WithCancel(context.Background())
defer cancelRecovery()
// Recovery is deliberately asynchronous: liveness and incident inspection
// remain available while committed-range checks run. Readiness becomes
// true only after the real SQLite/segment recovery completes.
go func() {
if _, recoverErr := persistence.RecoverCommandSegments(recoveryContext); recoverErr != nil {
health.SetDirty(true)
if incidentID, idErr := domain.NewUUIDv7(); idErr == nil {
if _, incidentErr := persistence.RecordIncident(context.Background(), store.IncidentInput{
IncidentUUID: incidentID, DetectedAt: time.Now().UTC(), Kind: store.IncidentSQLiteIntegrity,
Scope: store.IncidentScopeGlobal, ScopeKey: "server-startup-recovery", Summary: "startup storage recovery failed",
Evidence: []byte(recoverErr.Error()), AutomaticallyRepairable: false,
}); incidentErr != nil {
log.Printf("rvbox-server: could not persist recovery incident: %v", incidentErr)
}
}
log.Printf("rvbox-server: storage recovery left readiness disabled: %v", recoverErr)
return
}
health.SetReady(true)
}()
listener, err := net.Listen("tcp", configured.Server.AgentListen)
if err != nil {
@@ -106,9 +160,12 @@ func run(configPath string) error {
HeartbeatIdle: configured.Protocol.HeartbeatIdle, LivenessTimeout: configured.Protocol.LivenessTimeout,
}
httpServer := &http.Server{Handler: agent, ReadHeaderTimeout: configured.Flow.WriteDeadline}
serveError := make(chan error, 3)
serveError := make(chan error, 4)
go func() { serveError <- httpServer.Serve(listener) }()
go func() { serveError <- grpcServer.Serve(controlListener) }()
if healthListener != nil {
go func() { serveError <- healthServer.Serve(healthListener) }()
}
if rpcServer != nil {
go func() { serveError <- rpcServer.Serve(rpcListener) }()
}
@@ -119,6 +176,9 @@ func run(configPath string) error {
select {
case err := <-serveError:
if errors.Is(err, http.ErrServerClosed) {
if healthListener != nil {
_ = healthServer.Close()
}
if rpcServer != nil {
_ = rpcServer.Close()
}
@@ -126,6 +186,9 @@ func run(configPath string) error {
return nil
}
_ = httpServer.Close()
if healthListener != nil {
_ = healthServer.Close()
}
if rpcServer != nil {
_ = rpcServer.Close()
}
@@ -135,6 +198,13 @@ func run(configPath string) error {
shutdownContext, cancel := context.WithTimeout(context.Background(), configured.Server.ShutdownGrace)
defer cancel()
httpErr := httpServer.Shutdown(shutdownContext)
var healthErr error
if healthListener != nil {
healthErr = healthServer.Shutdown(shutdownContext)
}
if httpErr == nil {
httpErr = healthErr
}
if rpcServer != nil {
if err := rpcServer.Shutdown(shutdownContext); httpErr == nil {
httpErr = err
+42 -5
View File
@@ -22,6 +22,7 @@ import (
"github.com/rvbox/rvbox/internal/client/windowsservice"
"github.com/rvbox/rvbox/internal/config"
"github.com/rvbox/rvbox/internal/domain"
"github.com/rvbox/rvbox/internal/observability"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -40,7 +41,7 @@ func run(args []string, output, diagnostics io.Writer) error {
return errors.New("an internal mode is required (use --help)")
}
if args[0] == "--help" || args[0] == "-h" {
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--check-config|--install-service|--uninstall-service|--start-service|--stop-service --config PATH\n")
_, err := io.WriteString(output, "usage: rvbox --service|--tray|--check-config|--install-service|--uninstall-service|--configure-service|--start-service|--stop-service|--restart-service --config PATH\n")
return err
}
flags := flag.NewFlagSet("rvbox", flag.ContinueOnError)
@@ -51,8 +52,11 @@ func run(args []string, output, diagnostics io.Writer) error {
checkConfig := flags.Bool("check-config", false, "validate client configuration and exit")
install := flags.Bool("install-service", false, "install or update the machine-wide service")
uninstall := flags.Bool("uninstall-service", false, "remove the machine-wide service")
configure := flags.Bool("configure-service", false, "configure machine-wide service startup mode")
startup := flags.String("startup", string(windowsservice.StartupAutomatic), "service startup mode: automatic or manual")
start := flags.Bool("start-service", false, "start the machine-wide service")
stop := flags.Bool("stop-service", false, "stop the machine-wide service")
restart := flags.Bool("restart-service", false, "restart the machine-wide service")
if err := flags.Parse(args); err != nil {
return err
}
@@ -60,7 +64,7 @@ func run(args []string, output, diagnostics io.Writer) error {
return fmt.Errorf("unexpected argument %q", flags.Arg(0))
}
selected := 0
for _, value := range []bool{*serviceMode, *trayMode, *checkConfig, *install, *uninstall, *start, *stop} {
for _, value := range []bool{*serviceMode, *trayMode, *checkConfig, *install, *uninstall, *configure, *start, *stop, *restart} {
if value {
selected++
}
@@ -69,10 +73,11 @@ func run(args []string, output, diagnostics io.Writer) error {
return errors.New("select exactly one rvbox mode")
}
if *checkConfig {
if _, err := loadClientConfig(*configPath); err != nil {
configured, err := loadClientConfig(*configPath)
if err != nil {
return err
}
_, err := fmt.Fprintf(output, "valid client configuration: %s\n", *configPath)
_, err = fmt.Fprintf(output, "valid client configuration: path=%s server_url=%s state_dir=%s daemon_cwd=%s observability_listen=%s\n", *configPath, configured.Client.ServerURL, configured.Client.StateDir, configured.Client.DaemonCWD, configured.Observability.Listen)
return err
}
if *install {
@@ -85,12 +90,18 @@ func run(args []string, output, diagnostics io.Writer) error {
if *uninstall {
return windowsservice.Uninstall()
}
if *configure {
return windowsservice.Configure(windowsservice.StartupMode(*startup))
}
if *start {
return windowsservice.Start()
}
if *stop {
return windowsservice.Stop(30)
}
if *restart {
return windowsservice.Restart(30)
}
if *trayMode {
return runTray(*configPath, diagnostics)
}
@@ -116,8 +127,15 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
if err != nil {
return err
}
health := observability.New()
go func() {
if serveErr := health.Serve(ctx, configured.Observability.Listen, observability.Paths{Liveness: configured.Observability.LivenessPath, Readiness: configured.Observability.ReadinessPath, Metrics: configured.Observability.MetricsPath}); serveErr != nil && ctx.Err() == nil && diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox client observability endpoint stopped: %v\n", serveErr)
}
}()
state, err := spool.Open(ctx, spool.Options{DataDir: configured.Client.StateDir, BusyTimeout: 5 * time.Second, TombstoneLimit: configured.Storage.TombstoneMaxEntries, MaxScriptBytes: configured.Execution.MaxScriptBytes, MaxExecutionSpecBytes: configured.Execution.MaxExecutionSpecBytes, QuotaLimits: spool.QuotaLimits{HardAllocationBytes: 1 << 20, CommandOutputBytes: configured.Storage.CommandOutputLimitBytes, CommandTotalBytes: configured.Storage.CommandTotalLimitBytes, ClientTotalBytes: configured.Storage.ClientTotalLimitBytes, CloseoutReserveBytes: configured.Storage.CommandCloseoutReserveBytes}})
if err != nil {
health.SetDirty(true)
return fmt.Errorf("open client durable state: %w", err)
}
defer state.Close()
@@ -134,7 +152,7 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
limits = agentproto.DefaultLimits()
}
eventReady := make(chan domain.UUID, 256)
supervised, err := clientwindows.NewSupervisor(clientwindows.NativeOptions{Shells: clientwindows.ShellPaths{CMD: configured.Shells.CMD, PowerShell: configured.Shells.PowerShell}, WorkRoot: configured.Client.DaemonCWD, MaxWrapperBytes: configured.Execution.MaxScriptBytes, MaxOutputChunk: configured.Execution.MaxRawChunkBytes, WindowsTermGrace: configured.Execution.WindowsTermGrace})
supervised, err := clientwindows.NewSupervisor(clientwindows.NativeOptions{Shells: clientwindows.ShellPaths{CMD: configured.Shells.CMD, PowerShell: configured.Shells.PowerShell}, WorkRoot: configured.Client.DaemonCWD, JobProfiles: clientJobProfiles(configured.Profiles), MaxWrapperBytes: configured.Execution.MaxScriptBytes, MaxOutputChunk: configured.Execution.MaxRawChunkBytes, WindowsTermGrace: configured.Execution.WindowsTermGrace})
if err != nil {
return fmt.Errorf("configure command supervisor: %w", err)
}
@@ -149,12 +167,14 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
}
runner := func() {
if _, checkErr := state.Check(ctx); checkErr != nil {
health.SetDirty(true)
if diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox client spool is dirty: %v\n", checkErr)
}
return
}
if recovered, recoverErr := state.RecoverLaunchUncertainty(ctx, time.Now().UTC()); recoverErr != nil {
health.SetDirty(true)
if diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox launch recovery failed: %v\n", recoverErr)
}
@@ -162,6 +182,7 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
} else if len(recovered) > 0 && diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox recovered %d uncertain launch(es)\n", len(recovered))
}
health.SetReady(true)
if runErr := agent.Run(ctx, agent.RunnerOptions{
Store: state,
Dial: func(dialContext context.Context) (agent.Transport, error) {
@@ -182,6 +203,22 @@ func runClientDaemon(ctx context.Context, configPath string, diagnostics io.Writ
return nil
}
func clientJobProfiles(profiles config.Profiles) map[string]clientwindows.JobProfile {
return map[string]clientwindows.JobProfile{
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_LIGHT.String(): toJobProfile(profiles.Light),
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_MEDIUM.String(): toJobProfile(profiles.CPUMedium),
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_CPU_HEAVY.String(): toJobProfile(profiles.CPUHeavy),
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_MEDIUM.String(): toJobProfile(profiles.MemMedium),
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_MEM_HEAVY.String(): toJobProfile(profiles.MemHeavy),
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_DISK_MEDIUM.String(): toJobProfile(profiles.DiskMedium),
rvboxv1.ExecutionProfile_EXECUTION_PROFILE_DISK_HEAVY.String(): toJobProfile(profiles.DiskHeavy),
}
}
func toJobProfile(profile config.Profile) clientwindows.JobProfile {
return clientwindows.JobProfile{RequiredControls: append([]string(nil), profile.RequiredControls...), CPUPercent: profile.CPUPercent, MemoryMaxBytes: profile.MemoryMaxBytes, PIDsMax: profile.PIDsMax, IOReadBPS: profile.WindowsIOReadBPS, IOWriteBPS: profile.WindowsIOWriteBPS}
}
func clientHTTPClient(settings config.TLS) (*http.Client, error) {
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12, ServerName: settings.ServerName} // #nosec G402 -- TLS 1.2 is the v1 floor.
if settings.CAFile != "" {
+75 -2
View File
@@ -9,8 +9,10 @@ import (
"io"
"os"
"path/filepath"
"strings"
"github.com/rvbox/rvbox/internal/client/windowsservice"
"github.com/rvbox/rvbox/internal/client/windowstray"
"golang.org/x/sys/windows/svc"
)
@@ -34,9 +36,80 @@ func runService(configPath string, diagnostics io.Writer) error {
}
func runWindowsService(configPath string, diagnostics io.Writer) error {
return windowsservice.Run(func(ctx context.Context) error { return runClientDaemon(ctx, configPath, diagnostics) })
return windowsservice.Run(func(ctx context.Context) error {
// The tray endpoint lives in the same LocalSystem service process. It
// has no store access; the handler below returns only bounded status/path
// data and rechecks SCM authorization in the native pipe adapter.
go func() {
if err := windowstray.Serve(ctx, func(requestContext context.Context, _ windowstray.Peer, request windowstray.Frame) (windowstray.Frame, error) {
return handleTrayRequest(requestContext, configPath, request)
}); err != nil && ctx.Err() == nil && diagnostics != nil {
_, _ = fmt.Fprintf(diagnostics, "rvbox tray endpoint stopped: %v\n", err)
}
}()
return runClientDaemon(ctx, configPath, diagnostics)
})
}
func runTray(configPath string, diagnostics io.Writer) error {
return errors.New("Windows tray frontend is not available in this build")
_ = configPath // the tray obtains the canonical paths from the service.
return windowstray.Run(context.Background(), diagnostics)
}
func handleTrayRequest(ctx context.Context, configPath string, request windowstray.Frame) (windowstray.Frame, error) {
response := windowstray.Frame{Action: windowstray.ActionStatus}
switch request.Action {
case windowstray.ActionStatus:
response.Payload = []byte("RVBox service=running")
case windowstray.ActionOpenConfig:
response.Payload = []byte(configPath)
case windowstray.ActionOpenLog:
configured, err := loadClientConfig(configPath)
if err != nil {
return response, err
}
logPath := configured.Observability.LogFile
if logPath == "" {
logPath = filepath.Join(filepath.Dir(configPath), "logs", "rvbox.log")
}
response.Payload = []byte(logPath)
case windowstray.ActionStartService:
if err := windowsservice.Start(); err != nil {
return response, err
}
response.Payload = []byte("RVBox service start requested")
case windowstray.ActionStopService:
// A service cannot synchronously wait for its own stop request from the
// pipe handler: the SCM callback must return so the process can unwind.
go func() { _ = windowsservice.Stop(30) }()
response.Payload = []byte("RVBox service stop requested")
case windowstray.ActionRestartService:
// Restart must be performed by the external UAC helper. If the tray is
// already elevated, its native fallback still launches the canonical
// --restart-service mode outside this service process.
return response, errors.New("restart requires the external service helper")
case windowstray.ActionSetAutomatic:
if err := windowsservice.Configure(windowsservice.StartupAutomatic); err != nil {
return response, err
}
response.Payload = []byte("RVBox service startup set to automatic")
case windowstray.ActionSetManual:
if err := windowsservice.Configure(windowsservice.StartupManual); err != nil {
return response, err
}
response.Payload = []byte("RVBox service startup set to manual")
case windowstray.ActionExitTray:
response.Payload = []byte("tray exit acknowledged")
default:
return response, fmt.Errorf("unknown tray action %d", request.Action)
}
if len(response.Payload) > 4<<10 {
response.Payload = []byte(strings.TrimSpace(string(response.Payload[:4<<10])))
}
select {
case <-ctx.Done():
return response, ctx.Err()
default:
return response, nil
}
}
+87 -2
View File
@@ -193,14 +193,99 @@ func stat(ctx context.Context, client rvboxv1.ControlClient, args []string, outp
return err
}
item := response.GetCommand()
renderCommandStat(output, item, time.Now().UTC())
return nil
}
// renderCommandStat keeps the human CLI useful when a caller does not have a
// protobuf-aware inspection tool. Durable lifecycle, expiry, retention, and
// Windows identity are rendered independently: a late terminal result must
// not erase the fact that the queue deadline was crossed.
func renderCommandStat(output io.Writer, item *rvboxv1.CommandRecord, now time.Time) {
if item == nil {
return
}
fmt.Fprintf(output, "command %s client=%s lifecycle=%s revision=%d events=%d\n", item.GetIssueUuid(), item.GetTargetClientId(), item.GetLifecycle(), item.GetCommandRevision(), item.GetLastEventSeq())
if item.GetQueueExpiryTime() != nil {
fmt.Fprintf(output, "queue_expiry=%s\n", item.GetQueueExpiryTime().AsTime().UTC().Format(time.RFC3339Nano))
expiry := item.GetQueueExpiryTime().AsTime().UTC()
fmt.Fprintf(output, "queue_expiry=%s\n", expiry.Format(time.RFC3339Nano))
if !now.IsZero() && !now.Before(expiry) {
fmt.Fprintln(output, "queue_expired=true")
}
}
if item.GetLateAfterExpiry() {
fmt.Fprintln(output, "late_after_expiry=true (terminal result arrived after queue expiry)")
}
if item.GetTerminalTime() != nil {
fmt.Fprintf(output, "terminal_time=%s\n", item.GetTerminalTime().AsTime().UTC().Format(time.RFC3339Nano))
}
return nil
if item.GetExitCode() != 0 || item.ExitCode != nil {
fmt.Fprintf(output, "exit_code=%d\n", item.GetExitCode())
}
if item.GetOutputTruncated() {
fmt.Fprintln(output, "output_truncated=true")
}
if item.GetOutputIncomplete() {
fmt.Fprintln(output, "output_incomplete=true")
}
if item.GetRetainedCompressedBytes() > 0 {
fmt.Fprintf(output, "retained_compressed_bytes=%d\n", item.GetRetainedCompressedBytes())
}
if rejection := item.GetRejection(); rejection != nil {
fmt.Fprintf(output, "rejection_code=%s rejection=%s\n", rejection.GetCode(), singleLine(rejection.GetMessage()))
}
if identity := item.GetWindowsExecutionIdentity(); identity != nil {
renderWindowsIdentity(output, item.GetSpec().GetElevated(), identity)
}
}
func renderWindowsIdentity(output io.Writer, requestedElevated bool, identity *rvboxv1.WindowsExecutionIdentity) {
fmt.Fprintf(output, "windows_requested_elevated=%t\n", requestedElevated)
if contexts := identity.GetAttemptedContexts(); len(contexts) > 0 {
names := make([]string, 0, len(contexts))
for _, context := range contexts {
names = append(names, windowsContextName(context))
}
fmt.Fprintf(output, "windows_attempted_contexts=%s\n", strings.Join(names, ","))
}
if identity.EffectiveContext != nil {
fmt.Fprintf(output, "windows_effective_context=%s\n", windowsContextName(identity.GetEffectiveContext()))
} else {
fmt.Fprintln(output, "windows_effective_context=none")
}
if identity.GetEffectiveUserSid() != "" {
fmt.Fprintf(output, "windows_effective_user_sid=%s\n", singleLine(identity.GetEffectiveUserSid()))
}
if identity.SessionId != nil {
fmt.Fprintf(output, "windows_session_id=%d\n", identity.GetSessionId())
}
if identity.GetSessionUserSid() != "" {
fmt.Fprintf(output, "windows_session_user_sid=%s\n", singleLine(identity.GetSessionUserSid()))
}
if identity.GetSelectionDetail() != "" {
fmt.Fprintf(output, "windows_selection_detail=%s\n", singleLine(identity.GetSelectionDetail()))
}
}
func windowsContextName(value rvboxv1.WindowsExecutionContext) string {
switch value {
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_LOCAL_SERVICE:
return "local-service"
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_LOCAL_SYSTEM:
return "local-system"
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER:
return "active-user"
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED:
return "active-user-elevated"
case rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM:
return "active-system"
default:
return "unspecified"
}
}
func singleLine(value string) string {
return strings.NewReplacer("\r", "\\r", "\n", "\\n", "\t", "\\t").Replace(value)
}
func runCommand(ctx context.Context, client rvboxv1.ControlClient, args []string, output, diagnostics io.Writer) error {
+31
View File
@@ -1,11 +1,13 @@
package main
import (
"bytes"
"path/filepath"
"testing"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestGlobalSocketAndCLIValueParsing_HP_CTL_11(t *testing.T) {
@@ -47,3 +49,32 @@ func TestGlobalRequestIDIsInjectedOnlyForMutations_HP_CTL_12(t *testing.T) {
t.Fatal("duplicate global request IDs accepted")
}
}
func TestRenderCommandStatShowsExpiryRetentionAndWindowsIdentity_HP_CTL_13(t *testing.T) {
t.Parallel()
expiry := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
sessionID := uint32(7)
effective := rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM
var output bytes.Buffer
renderCommandStat(&output, &rvboxv1.CommandRecord{
IssueUuid: "019c46f1-1d02-7000-8000-0000000000f2", TargetClientId: "win-a",
Lifecycle: rvboxv1.CommandLifecycle_COMMAND_SUCCEEDED, CommandRevision: 3,
LastEventSeq: 9, QueueExpiryTime: timestamppb.New(expiry), OutputIncomplete: true,
OutputTruncated: true, RetainedCompressedBytes: 1234, LateAfterExpiry: true,
WindowsExecutionIdentity: &rvboxv1.WindowsExecutionIdentity{
EffectiveContext: &effective, SessionId: &sessionID, EffectiveUserSid: "S-1-5-18",
SessionUserSid: "S-1-5-21-user", AttemptedContexts: []rvboxv1.WindowsExecutionContext{
rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED, effective,
}, SelectionDetail: "fallback\nused",
},
}, expiry.Add(time.Minute))
for _, want := range []string{
"queue_expired=true", "late_after_expiry=true", "output_truncated=true", "output_incomplete=true",
"retained_compressed_bytes=1234", "windows_attempted_contexts=active-user-elevated,active-system",
"windows_effective_context=active-system", "windows_session_id=7", "windows_selection_detail=fallback\\nused",
} {
if !bytes.Contains(output.Bytes(), []byte(want)) {
t.Errorf("stat output missing %q:\n%s", want, output.String())
}
}
}
+1 -1
View File
@@ -1294,7 +1294,7 @@ invariants must not.
| --- | --- |
| `clients` | client ID, most-recent platform/capabilities/CWD/version, durable instance ID, current generation, connection/last-seen timestamps, latest rejected live-conflict instance/time, unified charged command-storage total |
| `sessions` | opaque session ID, client ID, durable client-instance UUID, generation, opened/fenced/closed times, close reason |
| `commands` | UUID, client ID, indexed issue/queue-expiry/terminal times, lifecycle, revision, exit result, last event sequence, retention status, a Zstandard-compressed immutable execution-spec payload including plaintext environment values, and optional Windows selection attempts/effective identity |
| `commands` | UUID, client ID, indexed issue/queue-expiry/terminal times, lifecycle, revision, exit result, last event sequence, retention status, a bounded structured admission-rejection payload when the client declines dispatch, a Zstandard-compressed immutable execution-spec payload including plaintext environment values, and optional Windows selection attempts/effective identity |
| `command_payloads` | command UUID, payload kind (script or other command-owned blob), Zstandard compression, raw/stored sizes, digest, inline bytes or validated segment reference |
| `command_events` | command UUID + event sequence unique key, observed and server receipt times, event type, payload metadata, immutable duplicate checksum |
| `output_segments` | command UUID, segment ordinal/path, `committed_end_offset`, min/max event sequence, stream mix, compressed/raw byte totals, checksum, created time |
+2 -1
View File
@@ -79,7 +79,8 @@ history, process handles, and Job Objects. Normal service startup never shows
UAC and is not blocked by tray or interactive-user availability.
The same signed `rvbox.exe` has explicit `service`, `tray`, `install-service`,
`uninstall-service`, `configure-service`, per-command launcher, and signal-
`uninstall-service`, `start-service`, `stop-service`, `restart-service`,
`configure-service`, per-command launcher, and signal-
helper modes. Internal modes require SCM state or a service-created launch
proof. Task Scheduler is not used. The installer registers an unelevated per-
user tray launch through the machine-wide `Run` key. One tray may run in each
+5 -2
View File
@@ -82,8 +82,11 @@ cleanup.
test reference against source. A resettable Windows smoke VM is now available.
The exact headless VirtualBox/Guest Control adapter is
`scripts/windows/test-host.ps1`; it takes the VM name, baseline snapshot, and
guest credentials only from host environment variables, acquires an exclusive
lease, and never writes secrets to the repository. Use `Prepare`, `Run`,
guest identity/password-file only from host environment variables, acquires an
exclusive lease, and never writes secrets to the repository. Set
`RVBOX_WINDOWS_GUEST_PASSWORD_FILE` to a mode-600 file outside the repository;
the adapter passes it with VirtualBox `--passwordfile` and never accepts an
inline password. Use `Prepare`, `Run`,
`Collect`, `Stop`, and `Reset` in that order for a native run. The VM is the
minimum smoke lane, so deferred native multi-session/ambiguous-session, Server
Core, and older-build entries remain explicitly blocked until their own
+16 -4
View File
@@ -129,6 +129,10 @@ func (executor *Executor) launch(ctx context.Context, issue domain.UUID, revisio
if err != nil {
cancel()
_ = executor.Store.SetLaunchPhase(context.Background(), issue, domain.LaunchPhaseNone, "", 0)
var startErr *supervisor.StartError
if errors.As(err, &startErr) {
return executor.rejectWithIdentity(ctx, issue, revision, err, startErr.WindowsIdentity)
}
return executor.reject(ctx, issue, revision, err)
}
identity := process.Identity()
@@ -141,7 +145,7 @@ func (executor *Executor) launch(ctx context.Context, issue domain.UUID, revisio
executor.active[issue] = process
executor.cancel[issue] = cancel
executor.mu.Unlock()
if _, err := executor.Store.AppendLifecycle(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), revision, "process started", executor.Now()); err != nil {
if _, err := executor.Store.AppendLifecycleWithIdentity(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), revision, "process started", executor.Now(), identity.WindowsIdentity()); err != nil {
_, _ = executor.Supervisor.Signal(context.Background(), process, supervisor.SignalKill)
cancel()
executor.remove(issue)
@@ -187,22 +191,30 @@ func (executor *Executor) watch(ctx context.Context, issue domain.UUID, revision
if waitErr != nil {
detail = boundedError(waitErr)
}
if err := executor.appendLifecycle(context.Background(), issue, revision, phase, detail); err == nil {
if err := executor.appendLifecycleWithIdentity(context.Background(), issue, revision, phase, detail, process.Identity().WindowsIdentity()); err == nil {
executor.notify(issue)
}
executor.remove(issue)
}
func (executor *Executor) appendLifecycle(ctx context.Context, issue domain.UUID, revision uint64, phase rvboxv1.CommandLifecycle, detail string) error {
_, err := executor.Store.AppendLifecycle(ctx, issue, uint32(phase), revision, detail, executor.Now())
return executor.appendLifecycleWithIdentity(ctx, issue, revision, phase, detail, nil)
}
func (executor *Executor) appendLifecycleWithIdentity(ctx context.Context, issue domain.UUID, revision uint64, phase rvboxv1.CommandLifecycle, detail string, identity *rvboxv1.WindowsExecutionIdentity) error {
_, err := executor.Store.AppendLifecycleWithIdentity(ctx, issue, uint32(phase), revision, detail, executor.Now(), identity)
return err
}
func (executor *Executor) reject(ctx context.Context, issue domain.UUID, revision uint64, cause error) error {
return executor.rejectWithIdentity(ctx, issue, revision, cause, nil)
}
func (executor *Executor) rejectWithIdentity(ctx context.Context, issue domain.UUID, revision uint64, cause error, identity *rvboxv1.WindowsExecutionIdentity) error {
if revision == 0 {
return cause
}
if err := executor.appendLifecycle(ctx, issue, revision, rvboxv1.CommandLifecycle_COMMAND_REJECTED, boundedError(cause)); err != nil {
if err := executor.appendLifecycleWithIdentity(ctx, issue, revision, rvboxv1.CommandLifecycle_COMMAND_REJECTED, boundedError(cause), identity); err != nil {
return err
}
executor.notify(issue)
+24 -10
View File
@@ -94,12 +94,12 @@ func Run(ctx context.Context, options RunnerOptions) error {
if err := waitUntil(ctx, delay); err != nil {
return nil
}
sessionStarted := time.Now()
sessionStarted := options.Now()
_ = runOnce(ctx, options)
if ctx.Err() != nil {
return nil
}
if time.Since(sessionStarted) >= options.Backoff.StableReset {
if options.Now().Sub(sessionStarted) >= options.Backoff.StableReset {
failures = 0
}
if failures < ^uint32(0) {
@@ -357,11 +357,11 @@ func serveActive(ctx context.Context, transport Transport, options RunnerOptions
return err
}
case envelope.GetScriptChunk() != nil:
if err := handleScriptChunk(ctx, transport, options.Store, session, envelope.GetScriptChunk(), limits); err != nil {
if err := handleScriptChunk(ctx, transport, options.Store, session, envelope.GetScriptChunk(), limits, options.Now, sent); err != nil {
return err
}
case envelope.GetScriptCommit() != nil:
if err := handleScriptCommit(ctx, transport, options.Store, session, envelope.GetScriptCommit(), limits); err != nil {
if err := handleScriptCommit(ctx, transport, options.Store, session, envelope.GetScriptCommit(), limits, options.Now, sent); err != nil {
return err
}
if options.OnScriptReady != nil {
@@ -488,28 +488,38 @@ func boundedError(err error) string {
return message
}
func handleScriptChunk(ctx context.Context, transport Transport, store *spool.Store, session Session, chunk *rvboxv1.ScriptChunk, limits agentproto.Limits) error {
func handleScriptChunk(ctx context.Context, transport Transport, store *spool.Store, session Session, chunk *rvboxv1.ScriptChunk, limits agentproto.Limits, now func() time.Time, sent map[domain.UUID]uint64) error {
status, err := ApplyScriptChunk(ctx, store, session, chunk, limits)
if err != nil {
return err
}
return appendAndSendScriptStatus(ctx, transport, store, session, chunk.GetIssueUuid(), status, limits)
return appendAndSendScriptStatus(ctx, transport, store, session, chunk.GetIssueUuid(), status, limits, now, sent)
}
func handleScriptCommit(ctx context.Context, transport Transport, store *spool.Store, session Session, commit *rvboxv1.ScriptCommit, limits agentproto.Limits) error {
func handleScriptCommit(ctx context.Context, transport Transport, store *spool.Store, session Session, commit *rvboxv1.ScriptCommit, limits agentproto.Limits, now func() time.Time, sent map[domain.UUID]uint64) error {
status, err := ApplyScriptCommit(ctx, store, session, commit, limits)
if err != nil {
return err
}
return appendAndSendScriptStatus(ctx, transport, store, session, commit.GetIssueUuid(), status, limits)
return appendAndSendScriptStatus(ctx, transport, store, session, commit.GetIssueUuid(), status, limits, now, sent)
}
func appendAndSendScriptStatus(ctx context.Context, transport Transport, store *spool.Store, session Session, issueText string, status spool.ScriptStatus, limits agentproto.Limits) error {
func appendAndSendScriptStatus(ctx context.Context, transport Transport, store *spool.Store, session Session, issueText string, status spool.ScriptStatus, limits agentproto.Limits, now func() time.Time, sent map[domain.UUID]uint64) error {
issue, err := domain.ParseUUIDv7(issueText)
if err != nil {
return err
}
event := &rvboxv1.CommandEvent{IssueUuid: issueText, ObservedAt: timestamppb.New(time.Now().UTC()), Payload: &rvboxv1.CommandEvent_ScriptStatus{ScriptStatus: &rvboxv1.ScriptUploadStatus{ReceivedBytes: status.ReceivedBytes, Complete: status.Committed}}}
if sent == nil {
sent = make(map[domain.UUID]uint64)
}
if now == nil {
now = func() time.Time { return time.Now().UTC() }
}
observedAt := now()
if observedAt.IsZero() {
return errors.New("script status clock returned zero")
}
event := &rvboxv1.CommandEvent{IssueUuid: issueText, ObservedAt: timestamppb.New(observedAt), Payload: &rvboxv1.CommandEvent_ScriptStatus{ScriptStatus: &rvboxv1.ScriptUploadStatus{ReceivedBytes: status.ReceivedBytes, Complete: status.Committed}}}
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(event)
if err != nil {
return err
@@ -522,9 +532,13 @@ func appendAndSendScriptStatus(ctx context.Context, transport Transport, store *
return err
}
for _, item := range assigned {
if item.EventSeq == 0 || item.EventSeq <= sent[issue] {
continue
}
if err := SendStoredEvent(ctx, transport, session, item, limits); err != nil {
return err
}
sent[issue] = item.EventSeq
}
return nil
}
+22 -3
View File
@@ -267,14 +267,25 @@ func (store *Store) AssignSendWindow(ctx context.Context, issueUUID domain.UUID,
return nil, err
}
defer tx.Rollback()
var next uint64
err = tx.QueryRowContext(ctx, `SELECT next_event_seq FROM commands WHERE issue_uuid = ?`, issueUUID[:]).Scan(&next)
var next, lastAck, assignedBytes uint64
err = tx.QueryRowContext(ctx, `SELECT next_event_seq, last_server_ack,
COALESCE((SELECT sum(length(payload)) FROM events
WHERE issue_uuid = commands.issue_uuid AND event_seq IS NOT NULL AND event_seq > commands.last_server_ack), 0)
FROM commands WHERE issue_uuid = ?`, issueUUID[:]).Scan(&next, &lastAck, &assignedBytes)
if err == sql.ErrNoRows {
return nil, ErrUnknownCommand
}
if err != nil {
return nil, err
}
// Assigned rows remain pinned until cumulative acknowledgement. Never
// assign another row while that durable send window is full; otherwise a
// burst of local events could grow the pinned set without bound even though
// callers pass a per-command byte limit on every invocation.
if assignedBytes >= maximumStoredBytes {
return eventsBySequence(ctx, tx, issueUUID, 0)
}
maximumStoredBytes -= assignedBytes
rows, err := tx.QueryContext(ctx, `SELECT local_ordinal, length(payload) FROM events WHERE issue_uuid = ? AND event_seq IS NULL ORDER BY local_ordinal LIMIT ?`, issueUUID[:], maximumEvents)
if err != nil {
return nil, err
@@ -475,6 +486,14 @@ func (store *Store) RecoverLaunchUncertainty(ctx context.Context, now time.Time)
// crash between a terminal marker and its public event from creating a state
// that can be replayed as a second execution.
func (store *Store) AppendLifecycle(ctx context.Context, issueUUID domain.UUID, phase uint32, revision uint64, detail string, observedAt time.Time) (Event, error) {
return store.AppendLifecycleWithIdentity(ctx, issueUUID, phase, revision, detail, observedAt, nil)
}
// AppendLifecycleWithIdentity is the lifecycle boundary used by the client
// runtime when the Windows supervisor has captured an immutable selection
// record. Keeping the legacy wrapper above preserves the small store API for
// recovery and tests that have no platform identity to attach.
func (store *Store) AppendLifecycleWithIdentity(ctx context.Context, issueUUID domain.UUID, phase uint32, revision uint64, detail string, observedAt time.Time, identity *rvboxv1.WindowsExecutionIdentity) (Event, error) {
if !validUUID(issueUUID) || phase == 0 || phase > 11 || observedAt.IsZero() || revision == 0 {
return Event{}, errors.New("invalid lifecycle event")
}
@@ -503,7 +522,7 @@ func (store *Store) AppendLifecycle(ctx context.Context, issueUUID domain.UUID,
if !domain.CanTransition(rvboxv1.CommandLifecycle(current), rvboxv1.CommandLifecycle(phase)) {
return Event{}, fmt.Errorf("invalid lifecycle transition %s -> %s", rvboxv1.CommandLifecycle(current), rvboxv1.CommandLifecycle(phase))
}
lifecycle := &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle(phase), CommandRevision: revision, Detail: detail}
lifecycle := &rvboxv1.LifecycleChange{Lifecycle: rvboxv1.CommandLifecycle(phase), CommandRevision: revision, Detail: detail, WindowsExecutionIdentity: identity}
payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(&rvboxv1.CommandEvent{IssueUuid: issueUUID.String(), ObservedAt: timestamppb.New(observedAt), Payload: &rvboxv1.CommandEvent_Lifecycle{Lifecycle: lifecycle}})
if err != nil {
return Event{}, err
+42 -1
View File
@@ -13,6 +13,7 @@ import (
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/proto"
)
func TestExecutionSpecIsDurableAndQuotaCounted_HP_DISPATCH_07(t *testing.T) {
@@ -188,6 +189,38 @@ func TestSpoolAcceptanceSequencingAndAck_HP_CLIENT_07(t *testing.T) {
}
}
func TestSendWindowPinsUnacknowledgedBytes_BH_CLIENT_13(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := openTestStore(t, ctx, filepath.Join(t.TempDir(), "spool"), DefaultTombstoneLimit)
issue := testUUID(t, "019c46f1-1d02-7000-8000-000000000014")
command := testCommand(issue, []byte("send-window"))
now := time.Date(2026, time.September, 6, 12, 0, 0, 0, time.UTC)
if _, err := store.AcceptCommand(ctx, command, now); err != nil {
t.Fatal(err)
}
for index, payload := range [][]byte{[]byte("first"), []byte("second")} {
if _, err := store.AppendEvent(ctx, issue, EventInput{Kind: uint32(index + 1), Compression: 1, Payload: payload, CreatedAt: now.Add(time.Duration(index) * time.Second)}); err != nil {
t.Fatal(err)
}
}
assigned, err := store.AssignSendWindow(ctx, issue, 1, uint64(len("first")))
if err != nil || len(assigned) != 1 || assigned[0].EventSeq != 1 {
t.Fatalf("first bounded window = %#v, %v", assigned, err)
}
assigned, err = store.AssignSendWindow(ctx, issue, 1, uint64(len("first")))
if err != nil || len(assigned) != 1 || assigned[0].EventSeq != 1 {
t.Fatalf("full pinned window assigned more data = %#v, %v", assigned, err)
}
if err := store.Ack(ctx, issue, 1); err != nil {
t.Fatal(err)
}
assigned, err = store.AssignSendWindow(ctx, issue, 1, uint64(len("second")))
if err != nil || len(assigned) != 1 || assigned[0].EventSeq != 2 {
t.Fatalf("window after cumulative ack = %#v, %v", assigned, err)
}
}
func TestAppendLifecycleAtomicallyUpdatesPhaseAndEvent_HP_CLIENT_11(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -199,7 +232,9 @@ func TestAppendLifecycleAtomicallyUpdatesPhaseAndEvent_HP_CLIENT_11(t *testing.T
if _, err := store.AcceptCommand(ctx, command, now); err != nil {
t.Fatal(err)
}
if _, err := store.AppendLifecycle(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), 1, "launch authorized", now); err != nil {
effective := rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER
identity := &rvboxv1.WindowsExecutionIdentity{EffectiveContext: &effective, SessionId: ptrUint32(1), SessionUserSid: "S-1-5-21-user", EffectiveUserSid: "S-1-5-21-user", AttemptedContexts: []rvboxv1.WindowsExecutionContext{effective}, SelectionDetail: "selected"}
if _, err := store.AppendLifecycleWithIdentity(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_RUNNING), 1, "launch authorized", now, identity); err != nil {
t.Fatal(err)
}
if _, err := store.AppendLifecycle(ctx, issue, uint32(rvboxv1.CommandLifecycle_COMMAND_SUCCEEDED), 1, "exit 0", now.Add(time.Second)); err != nil {
@@ -223,8 +258,14 @@ func TestAppendLifecycleAtomicallyUpdatesPhaseAndEvent_HP_CLIENT_11(t *testing.T
if err != nil || len(events) != 2 {
t.Fatalf("lifecycle events = %#v, %v", events, err)
}
var decoded rvboxv1.CommandEvent
if err := proto.Unmarshal(events[0].Payload, &decoded); err != nil || decoded.GetLifecycle().GetWindowsExecutionIdentity().GetEffectiveContext() != effective {
t.Fatalf("running identity event = %v, %v", decoded.GetLifecycle(), err)
}
}
func ptrUint32(value uint32) *uint32 { return &value }
func TestTerminalCleanupTombstonesAndConflicts_BH_CLIENT_02(t *testing.T) {
t.Parallel()
ctx := context.Background()
+72 -6
View File
@@ -17,6 +17,29 @@ var (
ErrUnsupported = errors.New("supervisor operation is unsupported")
)
// StartError carries durable context-selection evidence when a command is
// rejected before a process exists. The runtime records the evidence with
// the rejection event without treating the error as a second public protocol
// type. Unwrap keeps ordinary retry/error classification working.
type StartError struct {
Cause error
WindowsIdentity *rvboxv1.WindowsExecutionIdentity
}
func (err *StartError) Error() string {
if err == nil || err.Cause == nil {
return "supervisor start failed"
}
return err.Cause.Error()
}
func (err *StartError) Unwrap() error {
if err == nil {
return nil
}
return err.Cause
}
type SignalKind uint8
const (
@@ -53,12 +76,55 @@ func (spec StartSpec) Validate() error {
// EffectiveIdentity is immutable process evidence captured before launch.
// Empty user/session fields mean a Session 0 service context.
type EffectiveIdentity struct {
Context string
SessionID uint32
UserSID string
LogonSID string
Elevated bool
Integrity string
Context string
SessionID uint32
SessionUserSID string
UserSID string
LogonSID string
Elevated bool
Integrity string
AttemptedContexts []string
SelectionDetail string
}
// WindowsIdentity converts the platform-neutral evidence to the public
// immutable status/event shape. Unknown contexts are deliberately omitted so
// the portable test supervisor never pretends to be a Windows launch.
func (identity EffectiveIdentity) WindowsIdentity() *rvboxv1.WindowsExecutionIdentity {
result := &rvboxv1.WindowsExecutionIdentity{SessionUserSid: identity.SessionUserSID, EffectiveUserSid: identity.UserSID, SelectionDetail: identity.SelectionDetail}
for _, contextName := range identity.AttemptedContexts {
if context, ok := windowsExecutionContext(contextName); ok {
result.AttemptedContexts = append(result.AttemptedContexts, context)
}
}
if context, ok := windowsExecutionContext(identity.Context); ok {
result.EffectiveContext = &context
if context == rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER || context == rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED || context == rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM {
session := identity.SessionID
result.SessionId = &session
}
}
if result.EffectiveContext == nil && len(result.AttemptedContexts) == 0 && result.SelectionDetail == "" && result.SessionUserSid == "" && result.EffectiveUserSid == "" {
return nil
}
return result
}
func windowsExecutionContext(value string) (rvboxv1.WindowsExecutionContext, bool) {
switch value {
case "LOCAL_SERVICE":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_LOCAL_SERVICE, true
case "LOCAL_SYSTEM":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_LOCAL_SYSTEM, true
case "ACTIVE_USER":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER, true
case "ACTIVE_USER_ELEVATED":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED, true
case "ACTIVE_SYSTEM":
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM, true
default:
return rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_UNSPECIFIED, false
}
}
type Process interface {
@@ -30,3 +30,25 @@ func TestStartSpecValidation_BH_SUPERVISOR_01(t *testing.T) {
t.Fatal(err)
}
}
func TestWindowsIdentityMapsSelectionEvidence_HP_SUPERVISOR_05(t *testing.T) {
t.Parallel()
identity := EffectiveIdentity{
Context: "ACTIVE_SYSTEM",
SessionID: 7,
SessionUserSID: "S-1-5-21-user",
UserSID: "S-1-5-18",
AttemptedContexts: []string{"ACTIVE_USER_ELEVATED", "ACTIVE_SYSTEM"},
SelectionDetail: "ACTIVE_USER_ELEVATED: ELEVATION_UNAVAILABLE",
}
encoded := identity.WindowsIdentity()
if encoded == nil || encoded.GetEffectiveContext() != rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_SYSTEM || encoded.GetSessionId() != 7 || encoded.GetSessionUserSid() != "S-1-5-21-user" || encoded.GetEffectiveUserSid() != "S-1-5-18" {
t.Fatalf("mapped identity = %#v", encoded)
}
if len(encoded.GetAttemptedContexts()) != 2 || encoded.GetAttemptedContexts()[0] != rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER_ELEVATED {
t.Fatalf("attempted contexts = %v", encoded.GetAttemptedContexts())
}
if got := (EffectiveIdentity{AttemptedContexts: []string{"not-a-context"}}).WindowsIdentity(); got != nil {
t.Fatalf("unknown context evidence = %#v", got)
}
}
@@ -35,12 +35,26 @@ var (
type NativeOptions struct {
Shells ShellPaths
WorkRoot string
JobProfiles map[string]JobProfile
MaxWrapperBytes uint64
MaxOutputChunk uint64
WindowsTermGrace time.Duration
Now func() time.Time
}
// JobProfile is the validated administrator policy for one protocol profile.
// The native Windows adapter applies only controls it can set and read back
// atomically on a Job Object; a requested unsupported required control rejects
// the command before any child is created.
type JobProfile struct {
RequiredControls []string
CPUPercent uint64
MemoryMaxBytes uint64
PIDsMax uint64
IOReadBPS uint64
IOWriteBPS uint64
}
func (options NativeOptions) withDefaults() NativeOptions {
if options.MaxWrapperBytes == 0 {
options.MaxWrapperBytes = 10 << 20
@@ -69,17 +83,19 @@ func newExecSupervisor(options NativeOptions) *execSupervisor {
}
type execProcess struct {
issue domain.UUID
identity supervisor.EffectiveIdentity
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
outputs chan outputResult
done chan struct{}
started time.Time
waitFn func() (int32, bool, error)
killFn func(uint32) error
issue domain.UUID
identity supervisor.EffectiveIdentity
cmd *exec.Cmd
pid uint32
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
outputs chan outputResult
done chan struct{}
started time.Time
waitFn func() (int32, bool, error)
killFn func(uint32) error
snapshotFn func() (supervisor.ResourceSnapshot, error)
mu sync.Mutex
finished bool
@@ -219,7 +235,7 @@ func (process *execProcess) startReaders(maxChunk uint64, remove func()) {
}
func materializeWrapper(directory string, wrapper Wrapper, now time.Time) (string, func(), error) {
if directory == "" || !now.IsZero() && now.Location() == nil {
if directory == "" {
return "", nil, ErrInvalidWorkingDirectory
}
if err := os.MkdirAll(directory, 0o700); err != nil {
@@ -441,7 +457,11 @@ func (manager *execSupervisor) startCommand(ctx context.Context, spec supervisor
}
func (manager *execSupervisor) registerProcess(issue domain.UUID, identity supervisor.EffectiveIdentity, command *exec.Cmd, stdin io.WriteCloser, stdout, stderr io.ReadCloser, started time.Time, waitFn func() (int32, bool, error), killFn func(uint32) error, cleanup func()) *execProcess {
process := &execProcess{issue: issue, identity: identity, cmd: command, stdin: stdin, stdout: stdout, stderr: stderr, outputs: make(chan outputResult, 32), done: make(chan struct{}), started: started, waitFn: waitFn, killFn: killFn}
var pid uint32
if command != nil && command.Process != nil && command.Process.Pid > 0 {
pid = uint32(command.Process.Pid)
}
process := &execProcess{issue: issue, identity: identity, cmd: command, pid: pid, stdin: stdin, stdout: stdout, stderr: stderr, outputs: make(chan outputResult, 32), done: make(chan struct{}), started: started, waitFn: waitFn, killFn: killFn}
manager.mu.Lock()
manager.active[issue] = process
manager.mu.Unlock()
@@ -13,6 +13,7 @@ import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
@@ -27,11 +28,18 @@ const (
logon32LogonService = 5
logon32ProviderDefault = 0
securitySystemRID = "S-1-5-18"
disableMaxPrivilege = 0x1
)
var (
advapi32 = syscall.NewLazyDLL("advapi32.dll")
procLogonUserW = advapi32.NewProc("LogonUserW")
advapi32 = syscall.NewLazyDLL("advapi32.dll")
procLogonUserW = advapi32.NewProc("LogonUserW")
procCreateRestrictedToken = advapi32.NewProc("CreateRestrictedToken")
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procAttachConsole = kernel32.NewProc("AttachConsole")
procFreeConsole = kernel32.NewProc("FreeConsole")
procGenerateCtrlEvent = kernel32.NewProc("GenerateConsoleCtrlEvent")
procSetCtrlHandler = kernel32.NewProc("SetConsoleCtrlHandler")
)
func stdinLineEnding() []byte { return []byte{'\r', '\n'} }
@@ -130,6 +138,10 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
closeFiles()
return fail(fmt.Errorf("create command Job: %w", err))
}
if err := applyJobProfiles(job, manager.options.JobProfiles, spec.ExecutionProfiles); err != nil {
_ = winapi.CloseHandle(job)
return fail(err)
}
cleanupJob := true
defer func() {
if cleanupJob {
@@ -214,6 +226,9 @@ func (manager *execSupervisor) Start(ctx context.Context, spec supervisor.StartS
return winapi.TerminateJobObject(job, code)
}
process := manager.registerProcess(spec.IssueUUID, identity, command, stdinWrite, stdoutRead, stderrRead, started, waitFn, killFn, cleanup)
process.snapshotFn = func() (supervisor.ResourceSnapshot, error) {
return queryJobSnapshot(job, manager.options.Now())
}
return process, nil
}
@@ -280,6 +295,120 @@ func createKillOnCloseJob() (winapi.Handle, error) {
return job, nil
}
// applyJobProfiles combines the requested dimensions and applies them before
// process creation. The profile names originate from the validated protobuf
// ExecutionSpec; unknown names and any required control without a native
// implementation are permanent pre-launch failures.
func applyJobProfiles(job winapi.Handle, configured map[string]JobProfile, requested []string) error {
if len(requested) == 0 {
return nil
}
var combined JobProfile
for _, name := range requested {
profile, ok := configured[name]
if !ok {
return fmt.Errorf("%w: execution profile %q is not configured", supervisor.ErrUnsupported, name)
}
for _, required := range profile.RequiredControls {
switch required {
case "cpu", "memory", "pids":
case "io":
return fmt.Errorf("%w: Windows Job I/O rate control is not available in this build", supervisor.ErrUnsupported)
default:
return fmt.Errorf("%w: unknown required Job control %q", supervisor.ErrUnsupported, required)
}
}
if profile.CPUPercent > combined.CPUPercent {
combined.CPUPercent = profile.CPUPercent
}
if profile.MemoryMaxBytes > 0 && (combined.MemoryMaxBytes == 0 || profile.MemoryMaxBytes < combined.MemoryMaxBytes) {
combined.MemoryMaxBytes = profile.MemoryMaxBytes
}
if profile.PIDsMax > 0 && (combined.PIDsMax == 0 || profile.PIDsMax < combined.PIDsMax) {
combined.PIDsMax = profile.PIDsMax
}
if profile.IOReadBPS > 0 || profile.IOWriteBPS > 0 {
return fmt.Errorf("%w: Windows Job I/O rate control is not available in this build", supervisor.ErrUnsupported)
}
}
limits := winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
limits.BasicLimitInformation.LimitFlags = winapi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
if combined.MemoryMaxBytes > 0 {
if uint64(uintptr(combined.MemoryMaxBytes)) != combined.MemoryMaxBytes {
return fmt.Errorf("%w: memory profile exceeds native pointer size", supervisor.ErrUnsupported)
}
limits.ProcessMemoryLimit = uintptr(combined.MemoryMaxBytes)
limits.BasicLimitInformation.LimitFlags |= winapi.JOB_OBJECT_LIMIT_PROCESS_MEMORY
}
if combined.PIDsMax > 0 {
if combined.PIDsMax > uint64(^uint32(0)) {
return fmt.Errorf("%w: process-count profile exceeds Windows limit", supervisor.ErrUnsupported)
}
limits.BasicLimitInformation.ActiveProcessLimit = uint32(combined.PIDsMax)
limits.BasicLimitInformation.LimitFlags |= winapi.JOB_OBJECT_LIMIT_ACTIVE_PROCESS
}
if _, err := winapi.SetInformationJobObject(job, winapi.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits))); err != nil {
return fmt.Errorf("apply Windows Job limits: %w", err)
}
if combined.CPUPercent > 0 {
// The config contract expresses CPU allowance as a percentage of one
// logical CPU (for example 200 means two logical CPUs). Windows Job
// CpuRate is hundredths of a percentage of the whole machine, so scale
// by the active processor count before applying the hard cap. A profile
// larger than this host is intentionally capped at the host capacity,
// which means it imposes no additional CPU restriction but remains a
// valid, atomically verified profile.
processors := uint64(winapi.GetActiveProcessorCount(winapi.ALL_PROCESSOR_GROUPS))
if processors == 0 {
return fmt.Errorf("%w: Windows did not report an active processor count", supervisor.ErrUnsupported)
}
if combined.CPUPercent > (^uint64(0)-processors+1)/100 {
return fmt.Errorf("%w: CPU profile %d%% overflows Windows Job rate conversion", supervisor.ErrUnsupported, combined.CPUPercent)
}
cpuRate := (combined.CPUPercent*100 + processors - 1) / processors
if cpuRate > 10000 {
cpuRate = 10000
}
cpu := struct {
ControlFlags uint32
CPUrate uint32
Weight uint32
}{ControlFlags: 0x1 | 0x4 /* ENABLE | HARD_CAP */, CPUrate: uint32(cpuRate)}
if _, err := winapi.SetInformationJobObject(job, winapi.JobObjectCpuRateControlInformation, uintptr(unsafe.Pointer(&cpu)), uint32(unsafe.Sizeof(cpu))); err != nil {
return fmt.Errorf("apply Windows Job CPU limit: %w", err)
}
var cpuReadback struct {
ControlFlags uint32
CPUrate uint32
Weight uint32
}
var cpuReturned uint32
if err := winapi.QueryInformationJobObject(job, int32(winapi.JobObjectCpuRateControlInformation), uintptr(unsafe.Pointer(&cpuReadback)), uint32(unsafe.Sizeof(cpuReadback)), &cpuReturned); err != nil {
return fmt.Errorf("verify Windows Job CPU limit: %w", err)
}
if cpuReadback.CPUrate != cpu.CPUrate || cpuReadback.ControlFlags&0x5 != 0x5 {
return errors.New("Windows Job CPU limit did not read back as requested")
}
}
// Read back every requested limit before authorization. This catches
// policy restrictions and unsupported Job implementations early.
var readback winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
var returned uint32
if err := winapi.QueryInformationJobObject(job, int32(winapi.JobObjectExtendedLimitInformation), uintptr(unsafe.Pointer(&readback)), uint32(unsafe.Sizeof(readback)), &returned); err != nil {
return fmt.Errorf("verify Windows Job limits: %w", err)
}
if readback.BasicLimitInformation.LimitFlags&winapi.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0 {
return errors.New("Windows Job lost kill-on-close protection")
}
if combined.MemoryMaxBytes > 0 && uint64(readback.ProcessMemoryLimit) != combined.MemoryMaxBytes {
return errors.New("Windows Job memory limit did not read back as requested")
}
if combined.PIDsMax > 0 && uint64(readback.BasicLimitInformation.ActiveProcessLimit) != combined.PIDsMax {
return errors.New("Windows Job process limit did not read back as requested")
}
return nil
}
func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervisor.EffectiveIdentity, error) {
candidates, err := DiscoverActiveSessions()
if err != nil {
@@ -288,11 +417,39 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi
candidates = nil
}
selection := Select(SelectionInput{Elevated: elevated, ActiveSessions: candidates, ActiveSystemAvailable: true, LocalServiceAvailable: true, LocalSystemAvailable: true})
attempted := make([]string, 0, len(selection.Attempts)+1)
details := make([]string, 0, len(selection.Attempts)+1)
addAttempt := func(contextName ExecutionContext, detail string) {
for _, existing := range attempted {
if existing == string(contextName) {
if detail != "" {
details = append(details, string(contextName)+": "+detail)
}
return
}
}
attempted = append(attempted, string(contextName))
if detail != "" {
details = append(details, string(contextName)+": "+detail)
}
}
for _, attempt := range selection.Attempts {
addAttempt(attempt.Context, string(attempt.Reason))
}
withEvidence := func(identity supervisor.EffectiveIdentity) supervisor.EffectiveIdentity {
identity.AttemptedContexts = append([]string(nil), attempted...)
identity.SelectionDetail = boundSelectionDetail(strings.Join(details, "; "))
return identity
}
rejection := func(cause error) error {
identity := withEvidence(supervisor.EffectiveIdentity{})
return &supervisor.StartError{Cause: cause, WindowsIdentity: identity.WindowsIdentity()}
}
if selection.Effective == nil {
if selection.Error != nil {
return 0, supervisor.EffectiveIdentity{}, selection.Error
return 0, supervisor.EffectiveIdentity{}, rejection(selection.Error)
}
return 0, supervisor.EffectiveIdentity{}, errors.New("Windows execution context selection failed")
return 0, supervisor.EffectiveIdentity{}, rejection(errors.New("Windows execution context selection failed"))
}
var selected *SessionCandidate
if selection.Effective.SessionID != nil {
@@ -306,10 +463,11 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi
for _, attempt := range selection.Attempts {
token, identity, err := openTokenForAttempt(attempt.Context, selected)
if err == nil {
return token, identity, nil
return token, withEvidence(identity), nil
}
addAttempt(attempt.Context, "native preparation failed: "+err.Error())
if !elevated {
return 0, supervisor.EffectiveIdentity{}, err
return 0, supervisor.EffectiveIdentity{}, rejection(err)
}
}
// The pure selector stops as soon as ACTIVE_SYSTEM is available. A native
@@ -317,11 +475,22 @@ func (manager *execSupervisor) selectToken(elevated bool) (winapi.Token, supervi
// removed), so the final LOCAL_SYSTEM fallback is attempted here before
// launch preparation, never by retrying a created process.
if elevated {
addAttempt(ContextLocalSystem, "fallback")
if token, identity, err := openTokenForAttempt(ContextLocalSystem, nil); err == nil {
return token, identity, nil
return token, withEvidence(identity), nil
} else {
addAttempt(ContextLocalSystem, "native preparation failed: "+err.Error())
return 0, supervisor.EffectiveIdentity{}, rejection(err)
}
}
return 0, supervisor.EffectiveIdentity{}, errors.New("all Windows execution contexts failed before launch preparation")
return 0, supervisor.EffectiveIdentity{}, rejection(errors.New("all Windows execution contexts failed before launch preparation"))
}
func boundSelectionDetail(detail string) string {
if len(detail) <= 4096 {
return detail
}
return detail[:4096]
}
func openTokenForAttempt(contextName ExecutionContext, candidate *SessionCandidate) (winapi.Token, supervisor.EffectiveIdentity, error) {
@@ -350,12 +519,27 @@ func openTokenForAttempt(contextName ExecutionContext, candidate *SessionCandida
return 0, supervisor.EffectiveIdentity{}, err
}
identity.Context = string(ContextActiveSystem)
identity.SessionUserSID = candidate.UserSID
identity.LogonSID = candidate.LogonSID
return serviceToken, identity, nil
} else if token.IsElevated() {
// A full administrator token can be returned when UAC is disabled or
// policy supplies an already-unfiltered token. Normal commands must
// still run as that user without administrator authority; create a
// restricted medium token instead of silently falling back to a
// service identity.
restricted, err := createRestrictedMediumToken(token)
_ = token.Close()
return 0, supervisor.EffectiveIdentity{}, errors.New("active-user token is elevated and no restricted medium token was available")
if err != nil {
return 0, supervisor.EffectiveIdentity{}, err
}
token = restricted
}
identity := supervisor.EffectiveIdentity{Context: string(contextName), SessionID: candidate.SessionID, UserSID: candidate.UserSID, Elevated: contextName != ContextActiveUser, Integrity: map[bool]string{true: "high", false: "medium"}[contextName != ContextActiveUser]}
if err := verifyUserToken(token, candidate, contextName == ContextActiveUser); err != nil {
_ = token.Close()
return 0, supervisor.EffectiveIdentity{}, err
}
identity := supervisor.EffectiveIdentity{Context: string(contextName), SessionID: candidate.SessionID, SessionUserSID: candidate.UserSID, UserSID: candidate.UserSID, LogonSID: candidate.LogonSID, Elevated: contextName != ContextActiveUser, Integrity: map[bool]string{true: "high", false: "medium"}[contextName != ContextActiveUser]}
return token, identity, nil
case ContextLocalService:
token, err := logonLocalService()
@@ -370,6 +554,81 @@ func openTokenForAttempt(contextName ExecutionContext, candidate *SessionCandida
}
}
// createRestrictedMediumToken turns a full administrator token into the
// normal active-user token required for elevated=false. It disables all
// privileges, disables the built-in Administrators SID, and verifies medium
// integrity before the token is returned to the launch path.
func createRestrictedMediumToken(source winapi.Token) (winapi.Token, error) {
adminSID, err := winapi.CreateWellKnownSid(winapi.WinBuiltinAdministratorsSid)
if err != nil {
return 0, err
}
disabled := winapi.SIDAndAttributes{Sid: adminSID}
var restricted winapi.Token
result, _, callErr := procCreateRestrictedToken.Call(
uintptr(source), disableMaxPrivilege,
1, uintptr(unsafe.Pointer(&disabled)),
0, 0,
0, 0,
uintptr(unsafe.Pointer(&restricted)),
)
if result == 0 {
if callErr != syscall.Errno(0) {
return 0, callErr
}
return 0, syscall.GetLastError()
}
if err := setMediumIntegrity(restricted); err != nil {
_ = restricted.Close()
return 0, err
}
if restricted.IsElevated() {
_ = restricted.Close()
return 0, errors.New("restricted active-user token remained elevated")
}
return restricted, nil
}
func setMediumIntegrity(token winapi.Token) error {
mediumSID, err := winapi.StringToSid("S-1-16-8192")
if err != nil {
return err
}
sidLength := winapi.GetLengthSid(mediumSID)
headerSize := uint32(unsafe.Sizeof(winapi.Tokenmandatorylabel{}))
buffer := make([]byte, headerSize+sidLength)
label := (*winapi.Tokenmandatorylabel)(unsafe.Pointer(&buffer[0]))
label.Label.Sid = (*winapi.SID)(unsafe.Pointer(&buffer[headerSize]))
label.Label.Attributes = winapi.SE_GROUP_INTEGRITY | winapi.SE_GROUP_INTEGRITY_ENABLED
copy(buffer[headerSize:], unsafe.Slice((*byte)(unsafe.Pointer(mediumSID)), sidLength))
return winapi.SetTokenInformation(token, winapi.TokenIntegrityLevel, &buffer[0], uint32(len(buffer)))
}
func verifyUserToken(token winapi.Token, candidate *SessionCandidate, normal bool) error {
user, err := token.GetTokenUser()
if err != nil || user.User.Sid == nil {
if err != nil {
return err
}
return errors.New("active token has no user SID")
}
if user.User.Sid.String() != candidate.UserSID {
return errors.New("active token user SID changed during launch selection")
}
var sessionID uint32
var returned uint32
if err := winapi.GetTokenInformation(token, winapi.TokenSessionId, (*byte)(unsafe.Pointer(&sessionID)), uint32(unsafe.Sizeof(sessionID)), &returned); err != nil {
return err
}
if returned != uint32(unsafe.Sizeof(sessionID)) || sessionID != candidate.SessionID {
return errors.New("active token session changed during launch selection")
}
if normal && token.IsElevated() {
return errors.New("normal active-user token is elevated")
}
return nil
}
func duplicateServiceToken() (winapi.Token, supervisor.EffectiveIdentity, error) {
return duplicateServiceTokenForSession(0)
}
@@ -427,19 +686,66 @@ func (manager *execSupervisor) Signal(ctx context.Context, process supervisor.Pr
return supervisor.SignalOutcome{}, errors.New("unsupported signal")
}
if signal == supervisor.SignalTerm {
// The command has its own hidden console. A full AttachConsole/control
// helper is intentionally isolated from the Job kill path; if it is not
// available, the bounded grace period ends in an explicit Job kill.
// Each command has its own hidden console. The helper path is kept in
// this short-lived call and is deliberately best-effort: a session that
// has already exited or a policy that denies AttachConsole is recorded,
// then the bounded grace period ends in an explicit Job kill.
breakDelivered, breakErr := sendControlBreak(native.pid)
if breakErr != nil && ctx.Err() != nil {
return supervisor.SignalOutcome{}, ctx.Err()
}
if breakDelivered {
select {
case <-native.done:
return supervisor.SignalOutcome{Delivered: true, Detail: "CTRL_BREAK delivered", ObservedAt: manager.options.Now()}, nil
default:
}
}
select {
case <-ctx.Done():
return supervisor.SignalOutcome{}, ctx.Err()
case <-native.done:
return supervisor.SignalOutcome{Delivered: breakDelivered, Detail: "command exited after TERM", ObservedAt: manager.options.Now()}, nil
case <-time.After(manager.options.WindowsTermGrace):
}
}
if err := native.killFn(1); err != nil {
return supervisor.SignalOutcome{}, err
}
return supervisor.SignalOutcome{Delivered: true, Escalated: signal == supervisor.SignalTerm, Detail: "Windows Job terminated", ObservedAt: manager.options.Now()}, nil
detail := "Windows Job terminated"
if signal == supervisor.SignalTerm {
detail = "CTRL_BREAK grace expired; Windows Job terminated"
}
return supervisor.SignalOutcome{Delivered: true, Escalated: signal == supervisor.SignalTerm, Detail: detail, ObservedAt: manager.options.Now()}, nil
}
// sendControlBreak is the native equivalent of the signal-helper mode. The
// production helper is normally a separate short-lived rvbox.exe invocation;
// this direct implementation keeps the same verified PID/console boundary
// for the first service build and never addresses a process by a caller-
// supplied PID. The PID comes only from execProcess metadata.
func sendControlBreak(pid uint32) (bool, error) {
if pid == 0 {
return false, errors.New("command has no verified console PID")
}
if result, _, err := procAttachConsole.Call(uintptr(pid)); result == 0 {
if err == syscall.Errno(0) {
err = syscall.GetLastError()
}
return false, err
}
defer procFreeConsole.Call()
// Prevent the service/helper itself from acting on the generated event.
procSetCtrlHandler.Call(0, 1)
defer procSetCtrlHandler.Call(0, 0)
result, _, err := procGenerateCtrlEvent.Call(1 /* CTRL_BREAK_EVENT */, 0)
if result == 0 {
if err == syscall.Errno(0) {
err = syscall.GetLastError()
}
return false, err
}
return true, nil
}
func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.Process) (supervisor.ResourceSnapshot, error) {
@@ -450,6 +756,14 @@ func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.
if !ok || native.cmd == nil || native.cmd.Process == nil {
return supervisor.ResourceSnapshot{}, ErrProcessNotFound
}
if native.snapshotFn != nil {
select {
case <-ctx.Done():
return supervisor.ResourceSnapshot{}, ctx.Err()
default:
}
return native.snapshotFn()
}
select {
case <-ctx.Done():
return supervisor.ResourceSnapshot{}, ctx.Err()
@@ -458,6 +772,39 @@ func (manager *execSupervisor) Snapshot(ctx context.Context, process supervisor.
return supervisor.ResourceSnapshot{ProcessCount: 1, ObservedAt: manager.options.Now(), Complete: false, Detail: "Windows Job accounting is available after native completion integration"}, nil
}
type jobBasicAndIOAccounting struct {
TotalUserTime int64
TotalKernelTime int64
ThisPeriodTotalUserTime int64
ThisPeriodTotalKernelTime int64
TotalPageFaultCount uint32
TotalProcesses uint32
ActiveProcesses uint32
TotalTerminatedProcesses uint32
IO winapi.IO_COUNTERS
}
func queryJobSnapshot(job winapi.Handle, now time.Time) (supervisor.ResourceSnapshot, error) {
if job == 0 || job == winapi.InvalidHandle {
return supervisor.ResourceSnapshot{}, ErrProcessNotFound
}
var accounting jobBasicAndIOAccounting
var returned uint32
if err := winapi.QueryInformationJobObject(job, int32(winapi.JobObjectBasicAndIoAccountingInformation), uintptr(unsafe.Pointer(&accounting)), uint32(unsafe.Sizeof(accounting)), &returned); err != nil {
return supervisor.ResourceSnapshot{}, err
}
var limits winapi.JOBOBJECT_EXTENDED_LIMIT_INFORMATION
if err := winapi.QueryInformationJobObject(job, int32(winapi.JobObjectExtendedLimitInformation), uintptr(unsafe.Pointer(&limits)), uint32(unsafe.Sizeof(limits)), &returned); err != nil {
return supervisor.ResourceSnapshot{}, err
}
userKernel := accounting.TotalUserTime + accounting.TotalKernelTime
var cpu time.Duration
if userKernel > 0 && userKernel <= int64(^uint64(0)>>1)/100 {
cpu = time.Duration(userKernel) * 100 * time.Nanosecond
}
return supervisor.ResourceSnapshot{CPUTime: cpu, ResidentBytes: uint64(limits.PeakJobMemoryUsed), IOReadBytes: accounting.IO.ReadTransferCount, IOWriteBytes: accounting.IO.WriteTransferCount, ProcessCount: uint64(accounting.ActiveProcesses), ObservedAt: now, Complete: accounting.ActiveProcesses == 0, Detail: "Windows Job accounting"}, nil
}
func (manager *execSupervisor) StopAll(_ context.Context) error {
manager.mu.Lock()
processes := make([]*execProcess, 0, len(manager.active))
@@ -119,6 +119,8 @@ func Transition(state State, command Command) (State, error) {
// platform implementations return ErrUnsupported on non-Windows builds.
func Install(spec InstallSpec) error { return installNative(spec) }
func Uninstall() error { return uninstallNative() }
func Configure(startup StartupMode) error { return configureNative(startup) }
func Start() error { return startNative() }
func Stop(timeoutSeconds uint32) error { return stopNative(timeoutSeconds) }
func Restart(timeoutSeconds uint32) error { return restartNative(timeoutSeconds) }
func Run(run func(context.Context) error) error { return runNative(run) }
@@ -6,6 +6,8 @@ import "context"
func installNative(InstallSpec) error { return ErrUnsupported }
func uninstallNative() error { return ErrUnsupported }
func configureNative(StartupMode) error { return ErrUnsupported }
func startNative() error { return ErrUnsupported }
func stopNative(uint32) error { return ErrUnsupported }
func restartNative(uint32) error { return ErrUnsupported }
func runNative(func(context.Context) error) error { return ErrUnsupported }
@@ -10,6 +10,7 @@ import (
"time"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
)
@@ -67,6 +68,9 @@ func installNative(spec InstallSpec) error {
if err := service.Start(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_ALREADY_RUNNING) {
return fmt.Errorf("start %s service: %w", Name, err)
}
if err := registerTray(spec); err != nil {
return fmt.Errorf("register per-user tray: %w", err)
}
return nil
}
@@ -75,6 +79,7 @@ func serviceImage(spec InstallSpec) string {
}
func uninstallNative() error {
var serviceErr error
manager, err := connect()
if err != nil {
return fmt.Errorf("connect to service control manager: %w", err)
@@ -82,17 +87,79 @@ func uninstallNative() error {
defer manager.Disconnect()
service, err := manager.OpenService(Name)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return nil
serviceErr = nil
} else if err != nil {
return fmt.Errorf("open %s service: %w", Name, err)
} else {
defer service.Close()
if err := stopService(service, 30*time.Second); err != nil {
return err
}
if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) {
return fmt.Errorf("delete %s service: %w", Name, err)
}
}
if err := removeTrayRegistration(); err != nil {
return fmt.Errorf("remove per-user tray: %w", err)
}
return serviceErr
}
func configureNative(startup StartupMode) error {
if startup != StartupAutomatic && startup != StartupManual {
return ErrInvalidInstallSpec
}
manager, err := connect()
if err != nil {
return fmt.Errorf("connect to service control manager: %w", err)
}
defer manager.Disconnect()
service, err := manager.OpenService(Name)
if errors.Is(err, windows.ERROR_SERVICE_DOES_NOT_EXIST) {
return fmt.Errorf("%s service is not installed", Name)
}
if err != nil {
return fmt.Errorf("open %s service: %w", Name, err)
}
defer service.Close()
if err := stopService(service, 30*time.Second); err != nil {
configuration, err := service.Config()
if err != nil {
return fmt.Errorf("query %s service configuration: %w", Name, err)
}
if startup == StartupAutomatic {
configuration.StartType = mgr.StartAutomatic
} else {
configuration.StartType = mgr.StartManual
}
if err := service.UpdateConfig(configuration); err != nil {
return fmt.Errorf("update %s startup type: %w", Name, err)
}
return nil
}
const trayRunValue = "RVBoxTray"
func registerTray(spec InstallSpec) error {
key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
if err != nil {
return err
}
if err := service.Delete(); err != nil && !errors.Is(err, windows.ERROR_SERVICE_MARKED_FOR_DELETE) {
return fmt.Errorf("delete %s service: %w", Name, err)
defer key.Close()
image := syscall.EscapeArg(spec.ExecutablePath) + " --tray --config " + syscall.EscapeArg(spec.ConfigPath)
return key.SetStringValue(trayRunValue, image)
}
func removeTrayRegistration() error {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
if errors.Is(err, registry.ErrNotExist) {
return nil
}
if err != nil {
return err
}
defer key.Close()
if err := key.DeleteValue(trayRunValue); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
@@ -138,6 +205,13 @@ func stopNative(timeoutSeconds uint32) error {
return stopService(service, timeout)
}
func restartNative(timeoutSeconds uint32) error {
if err := stopNative(timeoutSeconds); err != nil {
return err
}
return startNative()
}
func stopService(service *mgr.Service, timeout time.Duration) error {
status, err := service.Query()
if err != nil {
+16 -1
View File
@@ -5,12 +5,25 @@ package windowstray
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"unicode/utf8"
)
// PipeName is the machine-local service endpoint used by every tray session.
// The native implementation creates it with PIPE_REJECT_REMOTE_CLIENTS and
// an explicit SYSTEM/Administrators/interactive-user ACL. Keeping the name
// here (rather than deriving it from user input) prevents cross-session and
// path-confusion bugs.
const PipeName = `\\.\pipe\RVBoxClientTrayV1`
// Handler is invoked by the service after the native adapter has verified the
// connecting process token, SID, and session. A response is always encoded
// as ActionStatus; requests other than status deliberately carry no payload.
type Handler func(context.Context, Peer, Frame) (Frame, error)
const (
protocolVersion uint16 = 1
maxFrameBytes = 64 << 10
@@ -33,6 +46,8 @@ const (
ActionStartService
ActionStopService
ActionRestartService
ActionSetAutomatic
ActionSetManual
ActionExitTray
)
@@ -115,7 +130,7 @@ func Authorize(peer Peer, action Action) error {
switch action {
case ActionStatus, ActionOpenConfig, ActionOpenLog, ActionExitTray:
return nil
case ActionStartService, ActionStopService, ActionRestartService:
case ActionStartService, ActionStopService, ActionRestartService, ActionSetAutomatic, ActionSetManual:
if peer.Administrator || peer.System {
return nil
}
@@ -0,0 +1,20 @@
//go:build !windows
package windowstray
import (
"context"
"errors"
"io"
)
var ErrUnsupported = errors.New("Windows tray IPC is unavailable on this platform")
// Serve exists on every platform so the service wiring stays build-tag
// neutral. Unix-like client support is deferred in v1; this endpoint must
// never silently become a second client daemon.
func Serve(context.Context, Handler) error { return ErrUnsupported }
func Request(context.Context, Frame) (Frame, error) { return Frame{}, ErrUnsupported }
func Run(context.Context, io.Writer) error { return ErrUnsupported }
@@ -0,0 +1,242 @@
//go:build windows
package windowstray
// This file is the small native service endpoint. It intentionally uses one
// request per pipe connection: the tray is a presentation client, not a
// long-lived command channel, and a bounded connection makes cancellation and
// peer verification straightforward. The service never hands the tray a
// store handle or command payload.
import (
"context"
"errors"
"fmt"
"io"
"os"
"time"
"unsafe"
winapi "golang.org/x/sys/windows"
)
const (
pipeBufferBytes = 64 << 10
pipeInstances = 8
)
var (
ErrUnsupported = errors.New("Windows tray IPC is unavailable on this platform")
pipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)"
)
// Serve accepts bounded tray requests until ctx is cancelled. It is safe to
// run before any interactive user logs in; in that state no client can pass
// the interactive-peer authorization check.
func Serve(ctx context.Context, handler Handler) error {
if handler == nil {
return errors.New("tray handler is required")
}
for {
pipe, err := newTrayPipe()
if err != nil {
return fmt.Errorf("create tray pipe: %w", err)
}
connected := make(chan error, 1)
go func() { connected <- winapi.ConnectNamedPipe(winapi.Handle(pipe.Fd()), nil) }()
select {
case <-ctx.Done():
_ = pipe.Close()
return nil
case err := <-connected:
if err != nil && !errors.Is(err, winapi.ERROR_PIPE_CONNECTED) {
_ = pipe.Close()
if ctx.Err() != nil {
return nil
}
continue
}
go serveTrayPipe(ctx, pipe, handler)
}
}
}
func newTrayPipe() (*os.File, error) {
name, err := winapi.UTF16PtrFromString(PipeName)
if err != nil {
return nil, err
}
descriptor, err := winapi.SecurityDescriptorFromString(pipeSDDL)
if err != nil {
return nil, err
}
attributes := &winapi.SecurityAttributes{
Length: uint32(unsafe.Sizeof(winapi.SecurityAttributes{})),
SecurityDescriptor: descriptor,
}
mode := uint32(winapi.PIPE_ACCESS_DUPLEX | winapi.PIPE_TYPE_MESSAGE | winapi.PIPE_READMODE_MESSAGE | winapi.PIPE_WAIT | winapi.PIPE_REJECT_REMOTE_CLIENTS | winapi.SECURITY_IDENTIFICATION)
handle, err := winapi.CreateNamedPipe(name, mode, pipeInstances, pipeBufferBytes, pipeBufferBytes, 0, 0, attributes)
if err != nil {
return nil, err
}
return os.NewFile(uintptr(handle), "rvbox-tray-pipe"), nil
}
func serveTrayPipe(ctx context.Context, pipe *os.File, handler Handler) {
if pipe == nil {
return
}
defer pipe.Close()
// A blocked Read must be interrupted when service shutdown cancels ctx.
readDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = pipe.Close()
case <-readDone:
}
}()
defer close(readDone)
peer, err := peerFromPipe(winapi.Handle(pipe.Fd()))
if err != nil {
writeTrayResponse(pipe, err)
return
}
request, err := readTrayFrame(pipe)
if err != nil {
writeTrayResponse(pipe, err)
return
}
if err := Authorize(peer, request.Action); err != nil {
writeTrayResponse(pipe, err)
return
}
response, err := handler(ctx, peer, request)
if err != nil {
writeTrayResponse(pipe, err)
return
}
if response.Action == 0 {
response.Action = ActionStatus
}
if response.Action != ActionStatus {
response = Frame{Action: ActionStatus}
}
writeTrayResponse(pipe, response)
}
func readTrayFrame(reader io.Reader) (Frame, error) {
buffer := make([]byte, maxFrameBytes)
count, err := reader.Read(buffer)
if err != nil {
return Frame{}, err
}
if count == len(buffer) {
return Frame{}, ErrFrameTooLarge
}
return Decode(buffer[:count])
}
func writeTrayResponse(writer *os.File, value any) {
frame := Frame{Action: ActionStatus}
switch response := value.(type) {
case Frame:
frame = response
case error:
message := response.Error()
if len(message) > maxPayloadBytes {
message = message[:maxPayloadBytes]
}
frame.Payload = []byte("error: " + message)
}
encoded, err := Encode(frame)
if err != nil {
return
}
_, _ = writer.Write(encoded)
_ = winapi.FlushFileBuffers(winapi.Handle(writer.Fd()))
}
func peerFromPipe(pipe winapi.Handle) (Peer, error) {
if pipe == 0 || pipe == winapi.InvalidHandle {
return Peer{}, ErrInvalidPeer
}
var pid uint32
if err := winapi.GetNamedPipeClientProcessId(pipe, &pid); err != nil || pid == 0 {
return Peer{}, ErrInvalidPeer
}
process, err := winapi.OpenProcess(winapi.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return Peer{}, ErrInvalidPeer
}
defer winapi.CloseHandle(process)
var token winapi.Token
if err := winapi.OpenProcessToken(process, winapi.TOKEN_QUERY, &token); err != nil {
return Peer{}, ErrInvalidPeer
}
defer token.Close()
user, err := token.GetTokenUser()
if err != nil || user.User.Sid == nil {
return Peer{}, ErrInvalidPeer
}
var sessionID uint32
var returned uint32
if err := winapi.GetTokenInformation(token, winapi.TokenSessionId, (*byte)(unsafe.Pointer(&sessionID)), uint32(unsafe.Sizeof(sessionID)), &returned); err != nil || returned != uint32(unsafe.Sizeof(sessionID)) {
return Peer{}, ErrInvalidPeer
}
adminSID, err := winapi.CreateWellKnownSid(winapi.WinBuiltinAdministratorsSid)
if err != nil {
return Peer{}, ErrInvalidPeer
}
admin, err := token.IsMember(adminSID)
if err != nil {
return Peer{}, ErrInvalidPeer
}
sid := user.User.Sid.String()
return Peer{PID: pid, SessionID: sessionID, SID: sid, TokenVerified: true, Interactive: sessionID != 0, Administrator: admin, System: sid == "S-1-5-18"}, nil
}
// Request opens exactly one local pipe connection and exchanges one frame.
// It retries only the transient ERROR_PIPE_BUSY state and never falls back to
// an arbitrary filesystem/socket path.
func Request(ctx context.Context, request Frame) (Frame, error) {
encoded, err := Encode(request)
if err != nil {
return Frame{}, err
}
var pipe *os.File
for {
if ctx.Err() != nil {
return Frame{}, ctx.Err()
}
name, nameErr := winapi.UTF16PtrFromString(PipeName)
if nameErr != nil {
return Frame{}, nameErr
}
handle, openErr := winapi.CreateFile(name, winapi.GENERIC_READ|winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, 0, 0)
if openErr == nil {
pipe = os.NewFile(uintptr(handle), "rvbox-tray-client")
break
}
if !errors.Is(openErr, winapi.ERROR_PIPE_BUSY) {
return Frame{}, openErr
}
timer := time.NewTimer(100 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return Frame{}, ctx.Err()
case <-timer.C:
}
}
defer pipe.Close()
state := uint32(winapi.PIPE_READMODE_MESSAGE)
_ = winapi.SetNamedPipeHandleState(winapi.Handle(pipe.Fd()), &state, nil, nil)
if _, err := pipe.Write(encoded); err != nil {
return Frame{}, err
}
if err := winapi.FlushFileBuffers(winapi.Handle(pipe.Fd())); err != nil {
return Frame{}, err
}
return readTrayFrame(pipe)
}
+419
View File
@@ -0,0 +1,419 @@
//go:build windows
package windowstray
// A deliberately small Win32 notification-area host. The tray has no
// durable state and no command execution path; it only renders a tooltip,
// sends enum actions over the authenticated service pipe, and opens paths
// returned by the service with the exact Windows Explorer executable.
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"unicode/utf8"
"unsafe"
winapi "golang.org/x/sys/windows"
)
const (
wmDestroy = 0x0002
wmClose = 0x0010
wmCommand = 0x0111
wmRButtonUp = 0x0205
wmLButtonDblClick = 0x0203
wmApp = 0x8000
trayMessage = wmApp + 1
trayIconID = 1
trayNIMAdd = 0
trayNIMModify = 1
trayNIMDelete = 2
trayNIFMessage = 0x00000001
trayNIFIcon = 0x00000002
trayNIFTip = 0x00000004
trayTPMRightBtn = 0x0002
trayMFString = 0x00000000
trayMFSeparator = 0x00000800
traySWHide = 0
trayIDIApplication = 32512
trayIDCArrow = 32512
)
var (
ErrTrayAlreadyRunning = errors.New("RVBox tray is already running in this session")
user32Tray = syscall.NewLazyDLL("user32.dll")
shell32Tray = syscall.NewLazyDLL("shell32.dll")
procRegisterClassEx = user32Tray.NewProc("RegisterClassExW")
procCreateWindowEx = user32Tray.NewProc("CreateWindowExW")
procDefWindowProc = user32Tray.NewProc("DefWindowProcW")
procGetMessage = user32Tray.NewProc("GetMessageW")
procTranslate = user32Tray.NewProc("TranslateMessage")
procDispatch = user32Tray.NewProc("DispatchMessageW")
procPostMessage = user32Tray.NewProc("PostMessageW")
procDestroyWindow = user32Tray.NewProc("DestroyWindow")
procShowWindow = user32Tray.NewProc("ShowWindow")
procPostQuitMessage = user32Tray.NewProc("PostQuitMessage")
procLoadIcon = user32Tray.NewProc("LoadIconW")
procLoadCursor = user32Tray.NewProc("LoadCursorW")
procCreatePopup = user32Tray.NewProc("CreatePopupMenu")
procAppendMenu = user32Tray.NewProc("AppendMenuW")
procTrackPopup = user32Tray.NewProc("TrackPopupMenu")
procDestroyMenu = user32Tray.NewProc("DestroyMenu")
procGetCursorPos = user32Tray.NewProc("GetCursorPos")
procSetForeground = user32Tray.NewProc("SetForegroundWindow")
procGetModuleHandle = user32Tray.NewProc("GetModuleHandleW")
procShellNotify = shell32Tray.NewProc("Shell_NotifyIconW")
trayCallback = syscall.NewCallback(trayWindowProc)
trayWindows sync.Map // hwnd -> *trayWindow
)
type trayPoint struct{ X, Y int32 }
type trayMessageRecord struct {
HWnd uintptr
Message uint32
WParam uintptr
LParam uintptr
Time uint32
Point trayPoint
}
type trayClass struct {
CbSize uint32
Style uint32
WndProc uintptr
CbClsExtra int32
CbWndExtra int32
HInstance uintptr
HIcon uintptr
HCursor uintptr
HbrBackground uintptr
MenuName *uint16
ClassName *uint16
HIconSm uintptr
}
type trayIconData struct {
CbSize uint32
HWnd uintptr
UID uint32
UFlags uint32
UCallbackMessage uint32
HIcon uintptr
Tip [128]uint16
State uint32
StateMask uint32
InfoFlags uint32
InfoTitle [64]uint16
InfoData [256]uint16
Guid [16]byte
BalloonIcon uintptr
}
type trayWindow struct {
hwnd uintptr
icon trayIconData
class *uint16
tooltip string
output io.Writer
closeOnce sync.Once
}
// Run starts one notification icon for the current logged-in session. It
// returns when the icon is closed, the service becomes unavailable, or ctx is
// cancelled; none of those outcomes affect the machine-wide service.
func Run(ctx context.Context, output io.Writer) error {
mutex, err := acquireTrayMutex()
if err != nil {
return err
}
defer winapi.CloseHandle(mutex)
response, err := Request(ctx, Frame{Action: ActionStatus})
if err != nil {
return fmt.Errorf("connect to RVBox service: %w", err)
}
tooltip := string(response.Payload)
if tooltip == "" {
tooltip = "RVBox service"
}
ready := make(chan *trayWindow, 1)
done := make(chan error, 1)
go runTrayMessageLoop(output, tooltip, ready, done)
var window *trayWindow
select {
case window = <-ready:
case err := <-done:
return err
case <-ctx.Done():
return nil
}
select {
case <-ctx.Done():
if window != nil {
_, _, _ = procPostMessage.Call(window.hwnd, wmClose, 0, 0)
}
return <-done
case err := <-done:
return err
}
}
func acquireTrayMutex() (winapi.Handle, error) {
var sessionID uint32
if err := winapi.ProcessIdToSessionId(winapi.GetCurrentProcessId(), &sessionID); err != nil {
return 0, err
}
name, err := winapi.UTF16PtrFromString(fmt.Sprintf("Local\\RVBoxTrayV1-%d", sessionID))
if err != nil {
return 0, err
}
mutex, err := winapi.CreateMutex(nil, true, name)
if errors.Is(err, winapi.ERROR_ALREADY_EXISTS) {
if mutex != 0 {
_ = winapi.CloseHandle(mutex)
}
return 0, ErrTrayAlreadyRunning
}
if err != nil {
return 0, err
}
return mutex, nil
}
func runTrayMessageLoop(output io.Writer, tooltip string, ready chan<- *trayWindow, done chan<- error) {
className, err := winapi.UTF16PtrFromString("RVBoxTrayWindowV1")
if err != nil {
done <- err
return
}
instance, _, _ := procGetModuleHandle.Call(0)
icon, _, _ := procLoadIcon.Call(0, uintptr(trayIDIApplication))
cursor, _, _ := procLoadCursor.Call(0, uintptr(trayIDCArrow))
class := trayClass{CbSize: uint32(unsafe.Sizeof(trayClass{})), WndProc: trayCallback, HInstance: instance, HIcon: icon, HCursor: cursor, ClassName: className, HIconSm: icon}
if result, _, callErr := procRegisterClassEx.Call(uintptr(unsafe.Pointer(&class))); result == 0 && !errors.Is(callErr, winapi.ERROR_CLASS_ALREADY_EXISTS) {
done <- callErr
return
}
title, _ := winapi.UTF16PtrFromString("RVBox")
hwnd, _, callErr := procCreateWindowEx.Call(0, uintptr(unsafe.Pointer(className)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, 0, 0, instance, 0)
if hwnd == 0 {
done <- callErr
return
}
window := &trayWindow{hwnd: hwnd, class: className, tooltip: tooltip, output: output}
window.icon = trayIconData{CbSize: uint32(unsafe.Sizeof(trayIconData{})), HWnd: hwnd, UID: trayIconID, UFlags: trayNIFMessage | trayNIFIcon | trayNIFTip, UCallbackMessage: trayMessage, HIcon: icon}
copy(window.icon.Tip[:], winapi.StringToUTF16(tooltip))
trayWindows.Store(hwnd, window)
if result, _, err := procShellNotify.Call(uintptr(trayNIMAdd), uintptr(unsafe.Pointer(&window.icon))); result == 0 {
trayWindows.Delete(hwnd)
_, _, _ = procDestroyWindow.Call(hwnd)
done <- err
return
}
_, _, _ = procShowWindow.Call(hwnd, traySWHide)
ready <- window
for {
var message trayMessageRecord
result, _, getErr := procGetMessage.Call(uintptr(unsafe.Pointer(&message)), 0, 0, 0)
if int32(result) == -1 {
window.removeIcon()
done <- getErr
return
}
if result == 0 {
window.removeIcon()
done <- nil
return
}
_, _, _ = procTranslate.Call(uintptr(unsafe.Pointer(&message)))
_, _, _ = procDispatch.Call(uintptr(unsafe.Pointer(&message)))
}
}
func trayWindowProc(hwnd uintptr, message uint32, wParam, lParam uintptr) uintptr {
value, _ := trayWindows.Load(hwnd)
window, _ := value.(*trayWindow)
switch message {
case wmClose:
_, _, _ = procDestroyWindow.Call(hwnd)
return 0
case wmDestroy:
if window != nil {
window.removeIcon()
}
trayWindows.Delete(hwnd)
procPostQuitMessage.Call(0)
return 0
case wmCommand:
if window != nil {
window.action(Action(uint16(wParam)))
}
return 0
case trayMessage:
if window != nil && (uint32(lParam) == wmRButtonUp || uint32(lParam) == wmLButtonDblClick) {
window.showMenu()
}
return 0
}
result, _, _ := procDefWindowProc.Call(hwnd, uintptr(message), wParam, lParam)
return result
}
func (window *trayWindow) removeIcon() {
window.closeOnce.Do(func() {
window.icon.UFlags = 0
_, _, _ = procShellNotify.Call(uintptr(trayNIMDelete), uintptr(unsafe.Pointer(&window.icon)))
})
}
func (window *trayWindow) showMenu() {
menu, _, _ := procCreatePopup.Call()
if menu == 0 {
return
}
defer procDestroyMenu.Call(menu)
add := func(action Action, label string) {
text, _ := winapi.UTF16PtrFromString(label)
procAppendMenu.Call(menu, trayMFString, uintptr(action), uintptr(unsafe.Pointer(text)))
}
add(ActionStatus, "Status")
add(ActionOpenConfig, "Open config")
add(ActionOpenLog, "Open log")
procAppendMenu.Call(menu, trayMFSeparator, 0, 0)
add(ActionStartService, "Start service")
add(ActionStopService, "Stop service")
add(ActionRestartService, "Restart service")
add(ActionSetAutomatic, "Start automatically")
add(ActionSetManual, "Start manually")
procAppendMenu.Call(menu, trayMFSeparator, 0, 0)
add(ActionExitTray, "Exit")
var point trayPoint
procGetCursorPos.Call(uintptr(unsafe.Pointer(&point)))
procSetForeground.Call(window.hwnd)
procTrackPopup.Call(menu, trayTPMRightBtn, uintptr(point.X), uintptr(point.Y), 0, window.hwnd, 0)
}
func (window *trayWindow) action(action Action) {
if action == ActionExitTray {
_, _, _ = procDestroyWindow.Call(window.hwnd)
return
}
response, err := Request(context.Background(), Frame{Action: action})
if err != nil {
window.setTooltip("RVBox service unavailable")
return
}
if isServiceMutation(action) && strings.HasPrefix(string(response.Payload), "error:") {
if err := runElevatedServiceAction(action); err == nil {
window.setTooltip("RVBox elevation requested")
return
}
}
if action == ActionOpenConfig || action == ActionOpenLog {
if err := openExactPath(string(response.Payload)); err != nil {
window.setTooltip("RVBox open failed")
}
return
}
if len(response.Payload) > 0 {
window.setTooltip(string(response.Payload))
}
}
func isServiceMutation(action Action) bool {
return action == ActionStartService || action == ActionStopService || action == ActionRestartService || action == ActionSetAutomatic || action == ActionSetManual
}
// runElevatedServiceAction is the tray's narrow UAC fallback. The service
// remains the authorization boundary; this helper only replays the enum as a
// canonical rvbox mode under an administrator token when the tray's filtered
// token cannot mutate SCM state directly.
func runElevatedServiceAction(action Action) error {
mode := ""
switch action {
case ActionStartService:
mode = "--start-service"
case ActionStopService:
mode = "--stop-service"
case ActionRestartService:
mode = "--restart-service"
case ActionSetAutomatic:
mode = "--configure-service --startup automatic"
case ActionSetManual:
mode = "--configure-service --startup manual"
default:
return errors.New("unsupported elevated tray action")
}
executable, err := os.Executable()
if err != nil {
return err
}
configPath := ""
for index := 0; index+1 < len(os.Args); index++ {
if os.Args[index] == "--config" {
configPath = os.Args[index+1]
break
}
}
if configPath == "" {
return errors.New("tray config path is unavailable")
}
args, err := winapi.UTF16PtrFromString(mode + " --config " + syscall.EscapeArg(configPath))
if err != nil {
return err
}
file, err := winapi.UTF16PtrFromString(executable)
if err != nil {
return err
}
if err := winapi.ShellExecute(0, mustUTF16("runas"), file, args, nil, winapi.SW_SHOWNORMAL); err != nil {
return err
}
return nil
}
func mustUTF16(value string) *uint16 {
encoded, _ := winapi.UTF16PtrFromString(value)
return encoded
}
func (window *trayWindow) setTooltip(value string) {
value = strings.TrimSpace(value)
if value == "" {
value = "RVBox service"
}
window.tooltip = value
window.icon.UFlags = trayNIFTip
for index := range window.icon.Tip {
window.icon.Tip[index] = 0
}
copy(window.icon.Tip[:], winapi.StringToUTF16(value))
_, _, _ = procShellNotify.Call(uintptr(trayNIMModify), uintptr(unsafe.Pointer(&window.icon)))
}
func openExactPath(path string) error {
if path == "" || strings.TrimSpace(path) != path || !utf8.ValidString(path) {
return errors.New("invalid path returned by service")
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() {
return errors.New("service path is not a regular file")
}
windowsDir, err := winapi.GetWindowsDirectory()
if err != nil {
return err
}
explorer := filepath.Join(windowsDir, "explorer.exe")
if info, err := os.Stat(explorer); err != nil || !info.Mode().IsRegular() {
return errors.New("Windows Explorer is unavailable")
}
return exec.Command(explorer, path).Start()
}
+151
View File
@@ -0,0 +1,151 @@
// Package observability provides the intentionally small health/metrics HTTP
// surface shared by the Linux server and Windows client. It contains no
// product state and never exposes command payloads or high-cardinality IDs.
package observability
import (
"context"
"fmt"
"net"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
type Paths struct {
Liveness string
Readiness string
Metrics string
}
type Health struct {
ready atomic.Bool
dirty atomic.Bool
mu sync.Mutex
count map[string]uint64
}
func New() *Health { return &Health{count: make(map[string]uint64)} }
func (health *Health) SetReady(value bool) {
if health != nil {
health.ready.Store(value)
}
}
func (health *Health) SetDirty(value bool) {
if health != nil {
health.dirty.Store(value)
}
}
func (health *Health) Inc(name string) {
if health == nil || !validMetricName(name) {
return
}
health.mu.Lock()
health.count[name]++
health.mu.Unlock()
}
func (health *Health) Snapshot() (ready, dirty bool, counters map[string]uint64) {
if health == nil {
return false, true, nil
}
health.mu.Lock()
defer health.mu.Unlock()
copyCounters := make(map[string]uint64, len(health.count))
for key, value := range health.count {
copyCounters[key] = value
}
return health.ready.Load(), health.dirty.Load(), copyCounters
}
func (health *Health) Handler(paths Paths) http.Handler {
if paths.Liveness == "" {
paths.Liveness = "/livez"
}
if paths.Readiness == "" {
paths.Readiness = "/readyz"
}
if paths.Metrics == "" {
paths.Metrics = "/metrics"
}
mux := http.NewServeMux()
mux.HandleFunc(paths.Liveness, func(response http.ResponseWriter, _ *http.Request) {
response.Header().Set("Content-Type", "text/plain; charset=utf-8")
response.WriteHeader(http.StatusOK)
_, _ = response.Write([]byte("live\n"))
})
mux.HandleFunc(paths.Readiness, func(response http.ResponseWriter, _ *http.Request) {
ready, dirty, _ := health.Snapshot()
response.Header().Set("Content-Type", "text/plain; charset=utf-8")
if !ready {
response.WriteHeader(http.StatusServiceUnavailable)
} else {
response.WriteHeader(http.StatusOK)
}
_, _ = fmt.Fprintf(response, "ready=%s dirty=%s\n", strconv.FormatBool(ready), strconv.FormatBool(dirty))
})
mux.HandleFunc(paths.Metrics, func(response http.ResponseWriter, _ *http.Request) {
ready, dirty, counters := health.Snapshot()
response.Header().Set("Content-Type", "text/plain; version=0.0.4")
response.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(response, "rvbox_health_ready %d\nrvbox_health_dirty %d\n", boolMetric(ready), boolMetric(dirty))
keys := make([]string, 0, len(counters))
for key := range counters {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
_, _ = fmt.Fprintf(response, "rvbox_%s_total %d\n", key, counters[key])
}
})
return mux
}
// Serve starts an endpoint and closes it when ctx is cancelled. The caller
// may use the returned error to keep listener failures visible without making
// product startup depend on a slow or unavailable metrics consumer.
func (health *Health) Serve(ctx context.Context, listen string, paths Paths) error {
if strings.TrimSpace(listen) == "" {
return fmt.Errorf("observability listen address is empty")
}
server := &http.Server{Addr: listen, Handler: health.Handler(paths), ReadHeaderTimeout: 10 * time.Second}
listener, err := net.Listen("tcp", listen)
if err != nil {
return err
}
go func() {
<-ctx.Done()
_ = server.Shutdown(context.Background())
}()
err = server.Serve(listener)
if err == http.ErrServerClosed {
return nil
}
return err
}
func boolMetric(value bool) int {
if value {
return 1
}
return 0
}
func validMetricName(name string) bool {
if name == "" {
return false
}
for _, character := range name {
if !(character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9') {
return false
}
}
return true
}
+42
View File
@@ -0,0 +1,42 @@
package observability
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHealthHandlerReadinessAndMetrics_HP_OPS_01(t *testing.T) {
t.Parallel()
health := New()
health.Inc("dispatch_attempt")
handler := health.Handler(Paths{Liveness: "/live", Readiness: "/ready", Metrics: "/metrics"})
ready := httptest.NewRecorder()
handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/ready", nil))
if ready.Code != http.StatusServiceUnavailable || !strings.Contains(ready.Body.String(), "ready=false") {
t.Fatalf("initial readiness = %d %q", ready.Code, ready.Body.String())
}
health.SetReady(true)
health.SetDirty(true)
ready = httptest.NewRecorder()
handler.ServeHTTP(ready, httptest.NewRequest(http.MethodGet, "/ready", nil))
if ready.Code != http.StatusOK || !strings.Contains(ready.Body.String(), "dirty=true") {
t.Fatalf("ready health = %d %q", ready.Code, ready.Body.String())
}
metrics := httptest.NewRecorder()
handler.ServeHTTP(metrics, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if metrics.Code != http.StatusOK || !strings.Contains(metrics.Body.String(), "rvbox_dispatch_attempt_total 1") {
t.Fatalf("metrics = %d %q", metrics.Code, metrics.Body.String())
}
}
func TestHealthMetricNamesAreBounded_BH_OPS_01(t *testing.T) {
t.Parallel()
health := New()
health.Inc("bad name")
_, _, counters := health.Snapshot()
if len(counters) != 0 {
t.Fatalf("invalid metric name recorded: %#v", counters)
}
}
+14 -5
View File
@@ -219,9 +219,8 @@ func (service *Service) GetCommand(ctx context.Context, request *rvboxv1.GetComm
return &rvboxv1.GetCommandResponse{Command: record}, nil
}
// RunCommand durably admits command-text work. Script payload persistence and
// chunk dispatch are intentionally kept behind the same immutable boundary and
// are rejected until their command_payloads path is wired to the dispatcher.
// RunCommand durably admits command-text and script work. Both forms share one
// immutable request boundary so replaying the same request ID is idempotent.
func (service *Service) RunCommand(ctx context.Context, request *rvboxv1.RunCommandRequest) (*rvboxv1.RunCommandResponse, error) {
if request == nil || request.GetTargetClientId() == "" || request.GetSpec() == nil {
return nil, controlError(codes.InvalidArgument, rvboxv1.ControlError_INVALID_ARGUMENT, "target_client_id and spec are required")
@@ -375,7 +374,10 @@ func (service *Service) GetOutput(ctx context.Context, request *rvboxv1.GetOutpu
if maxBytes > 16<<20 {
return nil, controlError(codes.InvalidArgument, rvboxv1.ControlError_INVALID_ARGUMENT, "max_bytes exceeds the control limit")
}
filterBytes := []byte(fmt.Sprintf("output\x00%s\x00%v", request.GetClientId(), streams))
// Bind the cursor to every selector that affects the result. In particular,
// issue_uuid must be included: otherwise a token issued for one command
// could be replayed against another command owned by the same client.
filterBytes := []byte(fmt.Sprintf("output\x00%s\x00%s\x00%v", request.GetClientId(), issue.String(), streams))
filter := domain.HashCursorFilters(filterBytes)
var eventSeq, offset, boundary uint64
if request.GetPageToken() != "" {
@@ -793,7 +795,14 @@ func commandRecord(view store.CommandView) (*rvboxv1.CommandRecord, error) {
if err := proto.Unmarshal(view.ExecutionSpec, spec); err != nil {
return nil, fmt.Errorf("decode stored execution spec: %w", err)
}
result := &rvboxv1.CommandRecord{IssueUuid: view.IssueUUID.String(), TargetClientId: view.ClientID, IssueTime: timestamppb.New(view.IssueTime), ServerReceiptTime: timestamppb.New(view.ServerReceiptTime), Spec: spec, Lifecycle: rvboxv1.CommandLifecycle(view.Lifecycle), LastEventSeq: view.LastEventSeq, OutputTruncated: view.OutputTruncated, OutputIncomplete: view.OutputIncomplete, RetainedCompressedBytes: view.RetainedCompressedBytes, CommandRevision: view.Revision}
result := &rvboxv1.CommandRecord{IssueUuid: view.IssueUUID.String(), TargetClientId: view.ClientID, IssueTime: timestamppb.New(view.IssueTime), ServerReceiptTime: timestamppb.New(view.ServerReceiptTime), Spec: spec, Lifecycle: rvboxv1.CommandLifecycle(view.Lifecycle), LastEventSeq: view.LastEventSeq, OutputTruncated: view.OutputTruncated, OutputIncomplete: view.OutputIncomplete, RetainedCompressedBytes: view.RetainedCompressedBytes, CommandRevision: view.Revision, LateAfterExpiry: view.LateAfterExpiry}
if len(view.Rejection) > 0 {
rejection := &rvboxv1.ControlError{}
if err := proto.Unmarshal(view.Rejection, rejection); err != nil {
return nil, fmt.Errorf("decode stored command rejection: %w", err)
}
result.Rejection = rejection
}
if view.QueueExpiryTime != nil {
result.QueueExpiryTime = timestamppb.New(*view.QueueExpiryTime)
}
+4
View File
@@ -240,6 +240,10 @@ func TestGetOutputSlicesWithAuthenticatedCursor_HP_CONTROL_12(t *testing.T) {
if _, err := service.GetOutput(context.Background(), &rvboxv1.GetOutputRequest{ClientId: "win-a", IssueUuid: issue.String(), Streams: []rvboxv1.StreamKind{rvboxv1.StreamKind_STREAM_STDERR}, MaxBytes: 3, PageToken: first.GetNextPageToken()}); status.Code(err) != codes.InvalidArgument {
t.Fatalf("changed output filter code = %v", status.Code(err))
}
otherIssue := fixedIssue(0xa8)
if _, err := service.GetOutput(context.Background(), &rvboxv1.GetOutputRequest{ClientId: "win-a", IssueUuid: otherIssue.String(), Streams: []rvboxv1.StreamKind{rvboxv1.StreamKind_STREAM_STDOUT}, MaxBytes: 3, PageToken: first.GetNextPageToken()}); status.Code(err) != codes.InvalidArgument {
t.Fatalf("changed output command code = %v", status.Code(err))
}
}
func TestControlMutationIdempotencyAndQueuedCancellation_BH_CONTROL_13(t *testing.T) {
+17 -6
View File
@@ -64,18 +64,30 @@ func (server *AgentServer) ServeHTTP(response http.ResponseWriter, request *http
http.Error(response, "agent session registry is unavailable", http.StatusServiceUnavailable)
return
}
started := time.Now()
heartbeat := newSynchronizedHeartbeat(server.heartbeatIdle(), server.livenessTimeout(), 0)
connection, err := websocket.Accept(response, request, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionDisabled,
// coder/websocket consumes control frames inside Read. Count ping and
// pong callbacks as inbound activity so a healthy, otherwise idle agent
// is not mistaken for a dead peer; application Read results are observed
// by serveConnection as usual.
OnPingReceived: func(context.Context, []byte) bool {
heartbeat.Observe(time.Since(started))
return true
},
OnPongReceived: func(context.Context, []byte) {
heartbeat.Observe(time.Since(started))
},
})
if err != nil {
return
}
defer connection.CloseNow()
server.serveConnection(request.Context(), connection)
server.serveConnection(request.Context(), connection, heartbeat, started)
}
func (server *AgentServer) serveConnection(parent context.Context, connection *websocket.Conn) {
started := time.Now()
func (server *AgentServer) serveConnection(parent context.Context, connection *websocket.Conn, heartbeat *synchronizedHeartbeat, started time.Time) {
messageType, payload, err := connection.Read(parent)
if err != nil {
return
@@ -194,7 +206,6 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
return
}
heartbeat := newSynchronizedHeartbeat(server.heartbeatIdle(), server.livenessTimeout(), 0)
writerDone := make(chan struct{})
go func() {
defer close(writerDone)
@@ -266,7 +277,7 @@ func (server *AgentServer) serveConnection(parent context.Context, connection *w
server.close(connection, websocket.StatusPolicyViolation, "invalid command acceptance")
return
}
if _, acceptErr := server.Store.RecordCommandAcceptance(sessionContext, issue, hello.GetClientId(), registration.Generation, acknowledgement.GetCommandRevision(), acknowledgement.GetAccepted(), server.now()); acceptErr != nil {
if _, acceptErr := server.Store.RecordCommandAcceptanceWithRejection(sessionContext, issue, hello.GetClientId(), registration.Generation, acknowledgement.GetCommandRevision(), acknowledgement.GetAccepted(), acknowledgement.GetRejection(), server.now()); acceptErr != nil {
server.close(connection, websocket.StatusPolicyViolation, "invalid command acceptance")
return
}
@@ -360,6 +371,7 @@ func eventAppendFromWire(event *rvboxv1.CommandEvent, clientID string, generatio
if lifecycle := event.GetLifecycle(); lifecycle != nil {
value := lifecycle.GetLifecycle()
result.Lifecycle, result.LifecycleRevision = &value, lifecycle.GetCommandRevision()
result.WindowsIdentity = lifecycle.GetWindowsExecutionIdentity()
}
if output := event.GetOutput(); output != nil {
result.Stream = uint16(output.GetStream())
@@ -707,7 +719,6 @@ func (server *AgentServer) writeLoop(ctx context.Context, connection *websocket.
if err != nil {
return
}
heartbeat.Observe(time.Since(started))
case HeartbeatClose:
return
}
+59 -1
View File
@@ -6,7 +6,9 @@ import (
"errors"
"time"
rvboxv1 "github.com/rvbox/rvbox/gen/go/rvbox/v1"
"github.com/rvbox/rvbox/internal/domain"
"google.golang.org/protobuf/proto"
)
var ErrDispatchMismatch = errors.New("command acceptance does not match dispatched generation or revision")
@@ -15,9 +17,31 @@ var ErrDispatchMismatch = errors.New("command acceptance does not match dispatch
// produced it. Repeated acknowledgements are harmless; a stale session cannot
// advance a newer dispatch or overwrite a terminal decision.
func (store *Store) RecordCommandAcceptance(ctx context.Context, issueUUID domain.UUID, clientID string, generation, revision uint64, accepted bool, now time.Time) (bool, error) {
return store.RecordCommandAcceptanceWithRejection(ctx, issueUUID, clientID, generation, revision, accepted, nil, now)
}
// RecordCommandAcceptanceWithRejection is the wire-facing variant that keeps
// a structured client admission rejection on the command row for status and
// history. The legacy method above remains convenient for reconciliation and
// tests that only have the accepted bit.
func (store *Store) RecordCommandAcceptanceWithRejection(ctx context.Context, issueUUID domain.UUID, clientID string, generation, revision uint64, accepted bool, rejection *rvboxv1.ControlError, now time.Time) (bool, error) {
if isZeroUUID([16]byte(issueUUID)) || clientID == "" || generation == 0 || revision == 0 || now.IsZero() {
return false, ErrDispatchMismatch
}
if accepted && rejection != nil {
return false, ErrDispatchMismatch
}
var storedRejection []byte
var err error
if rejection != nil {
if len(rejection.GetMessage()) > 4096 || len(rejection.GetIssueUuid()) > 128 {
return false, ErrDispatchMismatch
}
storedRejection, err = proto.MarshalOptions{Deterministic: true}.Marshal(rejection)
if err != nil || len(storedRejection) > 16<<10 {
return false, ErrDispatchMismatch
}
}
store.writeMu.Lock()
defer store.writeMu.Unlock()
database, err := store.openDatabase()
@@ -28,15 +52,37 @@ func (store *Store) RecordCommandAcceptance(ctx context.Context, issueUUID domai
if !accepted {
targetLifecycle = 11
}
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer tx.Rollback()
var queueExpiry sql.NullInt64
var lateAfterExpiry int
if err := tx.QueryRowContext(ctx, `SELECT queue_expiry_time, late_after_expiry FROM commands WHERE issue_uuid = ? AND client_id = ? AND target_session_generation = ? AND revision = ?`, issueUUID[:], clientID, generation, revision).Scan(&queueExpiry, &lateAfterExpiry); errors.Is(err, sql.ErrNoRows) {
_ = tx.Rollback()
return false, ErrDispatchMismatch
} else if err != nil {
return false, err
}
query := `UPDATE commands SET lifecycle = ?`
args := []any{targetLifecycle}
if !accepted {
query += `, terminal_time = ?`
args = append(args, now.UTC().UnixNano())
}
if rejection != nil {
query += `, rejection = ?`
args = append(args, storedRejection)
}
// The command may have been dispatched just before its queue deadline and
// its acceptance can arrive after that deadline. Preserve the eventual
// client truth, but durably retain the contradiction for status/audit.
query += `, late_after_expiry = CASE WHEN queue_expiry_time IS NOT NULL AND queue_expiry_time <= ? THEN 1 ELSE late_after_expiry END`
args = append(args, now.UTC().UnixNano())
query += ` WHERE issue_uuid = ? AND client_id = ? AND lifecycle = 2 AND target_session_generation = ? AND revision = ?`
args = append(args, issueUUID[:], clientID, generation, revision)
result, err := database.ExecContext(ctx, query, args...)
result, err := tx.ExecContext(ctx, query, args...)
if err != nil {
return false, err
}
@@ -45,8 +91,20 @@ func (store *Store) RecordCommandAcceptance(ctx context.Context, issueUUID domai
return false, err
}
if changed == 1 {
late := queueExpiry.Valid && now.UTC().UnixNano() >= queueExpiry.Int64
if late && lateAfterExpiry == 0 {
if err := appendLateExpiryAudit(ctx, tx, now.UTC().UnixNano(), clientID, [16]byte(issueUUID), now.UTC().UnixNano()); err != nil {
return false, err
}
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
if err := tx.Commit(); err != nil {
return false, err
}
var lifecycle uint32
var storedGeneration, storedRevision uint64
err = database.QueryRowContext(ctx, `SELECT lifecycle, COALESCE(target_session_generation, 0), revision FROM commands WHERE issue_uuid = ? AND client_id = ?`, issueUUID[:], clientID).Scan(&lifecycle, &storedGeneration, &storedRevision)
+21
View File
@@ -0,0 +1,21 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"fmt"
)
// appendLateExpiryAudit records the first observed queue-deadline
// contradiction without copying command text, script bodies, or output into
// the separate audit budget. The command row remains the fast status path;
// this row is the durable operator/audit history.
func appendLateExpiryAudit(ctx context.Context, tx *sql.Tx, occurredAt int64, clientID string, issue [16]byte, receiptUnixNano int64) error {
payload := []byte(fmt.Sprintf("receipt_unix_nano=%d", receiptUnixNano))
digest := sha256.Sum256(payload)
_, err := tx.ExecContext(ctx, `INSERT INTO audit_events (
occurred_at, source, client_id, issue_uuid, action, outcome, compression, payload, raw_bytes, stored_bytes, sha256
) VALUES (?, 'session', ?, ?, 'late_after_expiry', 'observed', 1, ?, ?, ?, ?)`, occurredAt, clientID, issue[:], payload, len(payload), len(payload), digest[:])
return err
}
+64
View File
@@ -126,6 +126,51 @@ func TestClaimDispatchExpiresAndFencesRequeue_HP_DISPATCH_02(t *testing.T) {
}
}
func TestLateDispatchAcceptanceAndEventRetainExpiryContradiction_BH_DISPATCH_09(t *testing.T) {
t.Parallel()
ctx := context.Background()
opened, err := Open(ctx, Options{DataDir: filepath.Join(t.TempDir(), "state"), BusyTimeout: time.Second})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = opened.Close() })
base := time.Date(2026, time.September, 6, 13, 0, 0, 0, time.UTC)
if _, err := opened.RegisterClientSession(ctx, ClientRegistration{
ClientID: "late-client", Platform: 2, Architecture: "amd64", DaemonVersion: "test", DaemonCWD: `C:\`, SupportedShells: []byte{1},
ClientInstanceID: [16]byte{21}, SessionID: [16]byte{22}, ConnectedAt: base,
}); err != nil {
t.Fatal(err)
}
issue := fixedStoreIssue(0xd6)
expiry := base.Add(time.Second)
if _, err := opened.QueueCommand(ctx, QueueCommandInput{IssueUUID: issue, ClientID: "late-client", IssueTime: base, ReceiptTime: base, QueueExpiryTime: &expiry, ImmutableSHA256: sha256.Sum256([]byte("late")), ExecutionSpec: []byte("spec")}); err != nil {
t.Fatal(err)
}
if candidate, err := opened.ClaimNextDispatch(ctx, "late-client", 7, base.Add(500*time.Millisecond)); err != nil || candidate == nil {
t.Fatalf("dispatch claim = %#v, %v", candidate, err)
}
if changed, err := opened.RecordCommandAcceptance(ctx, issue, "late-client", 7, 1, true, base.Add(2*time.Second)); err != nil || !changed {
t.Fatalf("late acceptance = %t, %v", changed, err)
}
var late int
if err := opened.DB().QueryRow(`SELECT late_after_expiry FROM commands WHERE issue_uuid = ?`, issue[:]).Scan(&late); err != nil || late != 1 {
t.Fatalf("late acceptance flag = %d, %v", late, err)
}
var audits int
if err := opened.DB().QueryRow(`SELECT COUNT(*) FROM audit_events WHERE action = 'late_after_expiry' AND issue_uuid = ?`, issue[:]).Scan(&audits); err != nil || audits != 1 {
t.Fatalf("late acceptance audit rows = %d, %v", audits, err)
}
if _, err := opened.AppendCommandEvent(ctx, EventAppend{IssueUUID: [16]byte(issue), ClientID: "late-client", SessionGeneration: 7, EventSeq: 1, ObservedUnixNano: base.Add(2 * time.Second).UnixNano(), ReceiptUnixNano: base.Add(2 * time.Second).UnixNano(), EventType: 5, Stream: 1, Compression: 1, RawLength: 1, Payload: []byte("x"), ImmutableSHA256: sha256.Sum256([]byte("event")), Output: true}); err != nil {
t.Fatal(err)
}
if err := opened.DB().QueryRow(`SELECT late_after_expiry FROM commands WHERE issue_uuid = ?`, issue[:]).Scan(&late); err != nil || late != 1 {
t.Fatalf("late event flag = %d, %v", late, err)
}
if err := opened.DB().QueryRow(`SELECT COUNT(*) FROM audit_events WHERE action = 'late_after_expiry' AND issue_uuid = ?`, issue[:]).Scan(&audits); err != nil || audits != 1 {
t.Fatalf("late event duplicated audit rows = %d, %v", audits, err)
}
}
func TestQueueAndClaimScriptPayloadIsDurable_HP_SCRIPT_02(t *testing.T) {
t.Parallel()
ctx := context.Background()
@@ -195,6 +240,25 @@ func TestRecordCommandAcceptanceFencesGeneration_HP_DISPATCH_05(t *testing.T) {
if changed, err := opened.RecordCommandAcceptance(ctx, issue, "win-client", 3, 1, true, time.Now()); err != nil || changed {
t.Fatalf("duplicate acceptance = %t, %v", changed, err)
}
rejectedIssue := fixedStoreIssue(0x96)
if _, err := opened.QueueCommand(ctx, QueueCommandInput{IssueUUID: rejectedIssue, ClientID: "win-client", IssueTime: time.Now(), ReceiptTime: time.Now(), ImmutableSHA256: sha256.Sum256([]byte("reject")), ExecutionSpec: []byte("spec")}); err != nil {
t.Fatal(err)
}
if _, err := opened.ClaimNextDispatch(ctx, "win-client", 3, time.Now()); err != nil {
t.Fatal(err)
}
rejection := &rvboxv1.ControlError{Code: rvboxv1.ControlError_INVALID_ARGUMENT, Message: "invalid command"}
if changed, err := opened.RecordCommandAcceptanceWithRejection(ctx, rejectedIssue, "win-client", 3, 1, false, rejection, time.Now()); err != nil || !changed {
t.Fatalf("rejection acceptance = %t, %v", changed, err)
}
view, err := opened.GetCommandView(ctx, "win-client", rejectedIssue)
if err != nil {
t.Fatal(err)
}
stored := &rvboxv1.ControlError{}
if err := proto.Unmarshal(view.Rejection, stored); err != nil || stored.GetCode() != rejection.GetCode() || stored.GetMessage() != rejection.GetMessage() {
t.Fatalf("stored rejection = %v, %v", stored, err)
}
}
func timePtr(value time.Time) *time.Time { return &value }
+32 -4
View File
@@ -50,6 +50,7 @@ type EventAppend struct {
ImmutableSHA256 [32]byte
Lifecycle *rvboxv1.CommandLifecycle
LifecycleRevision uint64
WindowsIdentity *rvboxv1.WindowsExecutionIdentity
Output bool
UseCloseout bool
}
@@ -79,6 +80,16 @@ func (store *Store) AppendCommandEvent(ctx context.Context, event EventAppend) (
if err != nil {
return result, err
}
var windowsIdentity []byte
if event.WindowsIdentity != nil {
if len(event.WindowsIdentity.GetAttemptedContexts()) > 8 || len(event.WindowsIdentity.GetSelectionDetail()) > 4096 {
return result, ErrInvalidSegmentRecord
}
windowsIdentity, err = proto.MarshalOptions{Deterministic: true}.Marshal(event.WindowsIdentity)
if err != nil || len(windowsIdentity) > 16<<10 {
return result, ErrInvalidSegmentRecord
}
}
store.writeMu.Lock()
defer store.writeMu.Unlock()
@@ -89,10 +100,13 @@ func (store *Store) AppendCommandEvent(ctx context.Context, event EventAppend) (
var lastSequence, commandOutputCharged, commandCharged, clientCharged, serverCharged, closeoutRemaining uint64
var currentLifecycle uint32
var currentRevision uint64
var lateAfterExpiry int
var queueExpiry sql.NullInt64
var clientID string
query := `SELECT commands.last_event_seq, commands.output_charged_bytes,
commands.charged_bytes, commands.closeout_remaining_bytes, commands.client_id, clients.charged_bytes,
storage_counters.command_charged_bytes, commands.lifecycle, commands.revision
storage_counters.command_charged_bytes, commands.lifecycle, commands.revision,
commands.queue_expiry_time, commands.late_after_expiry
FROM commands JOIN clients ON clients.client_id = commands.client_id
JOIN storage_counters ON storage_counters.singleton = 1 WHERE commands.issue_uuid = ?`
arguments := []any{event.IssueUUID[:]}
@@ -105,7 +119,7 @@ JOIN storage_counters ON storage_counters.singleton = 1 WHERE commands.issue_uui
arguments = append(arguments, event.SessionGeneration)
}
err = database.QueryRowContext(ctx, query, arguments...).Scan(
&lastSequence, &commandOutputCharged, &commandCharged, &closeoutRemaining, &clientID, &clientCharged, &serverCharged, &currentLifecycle, &currentRevision)
&lastSequence, &commandOutputCharged, &commandCharged, &closeoutRemaining, &clientID, &clientCharged, &serverCharged, &currentLifecycle, &currentRevision, &queueExpiry, &lateAfterExpiry)
if errors.Is(err, sql.ErrNoRows) {
return result, ErrCommandNotFound
}
@@ -133,6 +147,11 @@ JOIN storage_counters ON storage_counters.singleton = 1 WHERE commands.issue_uui
return result, domain.ValidateTransition(rvboxv1.CommandLifecycle(currentLifecycle), *event.Lifecycle)
}
}
late := queueExpiry.Valid && event.ReceiptUnixNano >= queueExpiry.Int64
lateValue := 0
if late {
lateValue = 1
}
rowsCharged, indexesCharged := uint64(1), uint64(1)
if store.willCreateSegment(event.IssueUUID, uint64(len(encoded))) {
rowsCharged++
@@ -215,8 +234,12 @@ payload, segment_ordinal, segment_record_offset, segment_record_length, immutabl
var update sql.Result
commandUpdate := `UPDATE commands SET last_event_seq = ?,
retained_compressed_bytes = retained_compressed_bytes + ?, output_charged_bytes = ?, charged_bytes = ?,
closeout_remaining_bytes = ?`
commandArgs := []any{event.EventSeq, len(event.Payload), reservation.CommandOutputCharged, reservation.CommandTotalCharged, reservation.CloseoutRemaining}
closeout_remaining_bytes = ?, late_after_expiry = CASE WHEN ? = 1 THEN 1 ELSE late_after_expiry END`
commandArgs := []any{event.EventSeq, len(event.Payload), reservation.CommandOutputCharged, reservation.CommandTotalCharged, reservation.CloseoutRemaining, lateValue}
if event.WindowsIdentity != nil {
commandUpdate = `UPDATE commands SET windows_execution_identity = ?, ` + commandUpdate[len("UPDATE commands SET "):]
commandArgs = append([]any{windowsIdentity}, commandArgs...)
}
if event.Lifecycle != nil {
terminal := 0
if domain.IsTerminal(*event.Lifecycle) {
@@ -236,6 +259,11 @@ retained_compressed_bytes = retained_compressed_bytes + ?, output_charged_bytes
}
}
}
if err == nil && late && lateAfterExpiry == 0 {
if err = appendLateExpiryAudit(ctx, tx, event.ReceiptUnixNano, clientID, event.IssueUUID, event.ReceiptUnixNano); err == nil {
lateAfterExpiry = 1
}
}
if err == nil && event.EventType == 7 {
// Stdin acknowledgements are command events, but their durable delivery
// cursor lives in stdin_writes. Mark the cumulative prefix in the same
+13 -1
View File
@@ -34,10 +34,22 @@ func TestReadCommandEventsValidatesSegmentAndPreservesOrder_HP_EVENT_04(t *testi
}
for sequence, lifecycle := range []rvboxv1.CommandLifecycle{rvboxv1.CommandLifecycle_COMMAND_RUNNING, rvboxv1.CommandLifecycle_COMMAND_SUCCEEDED} {
event := []byte{byte(sequence + 1), 'e', 'v', 't'}
if _, err := opened.AppendCommandEvent(ctx, EventAppend{IssueUUID: [16]byte(issue), ClientID: "win-client", SessionGeneration: 1, EventSeq: uint64(sequence + 1), ObservedUnixNano: now.UnixNano(), ReceiptUnixNano: now.Add(time.Duration(sequence) * time.Millisecond).UnixNano(), EventType: 4, Compression: 1, RawLength: uint64(len(event)), Payload: event, ImmutableSHA256: sha256.Sum256(event), Lifecycle: &lifecycle, LifecycleRevision: 1}); err != nil {
var identity *rvboxv1.WindowsExecutionIdentity
if sequence == 0 {
effective := rvboxv1.WindowsExecutionContext_WINDOWS_EXECUTION_CONTEXT_ACTIVE_USER
identity = &rvboxv1.WindowsExecutionIdentity{EffectiveContext: &effective, AttemptedContexts: []rvboxv1.WindowsExecutionContext{effective}, SelectionDetail: "selected"}
}
if _, err := opened.AppendCommandEvent(ctx, EventAppend{IssueUUID: [16]byte(issue), ClientID: "win-client", SessionGeneration: 1, EventSeq: uint64(sequence + 1), ObservedUnixNano: now.UnixNano(), ReceiptUnixNano: now.Add(time.Duration(sequence) * time.Millisecond).UnixNano(), EventType: 4, Compression: 1, RawLength: uint64(len(event)), Payload: event, ImmutableSHA256: sha256.Sum256(event), Lifecycle: &lifecycle, LifecycleRevision: 1, WindowsIdentity: identity}); err != nil {
t.Fatal(err)
}
}
view, err := opened.GetCommandView(ctx, "win-client", issue)
if err != nil {
t.Fatal(err)
}
if len(view.WindowsIdentity) == 0 {
t.Fatal("running lifecycle did not persist Windows identity")
}
events, err := opened.ReadCommandEvents(ctx, issue, 0, 10)
if err != nil || len(events) != 2 || events[0].EventSeq != 1 || events[1].EventSeq != 2 || string(events[1].Payload) != "\x02evt" {
t.Fatalf("read events = %#v, %v", events, err)
+16
View File
@@ -16,6 +16,8 @@ type migration struct {
var migrations = []migration{
{version: 1, sql: schemaV1},
{version: 2, sql: schemaV2},
{version: 3, sql: schemaV3},
{version: 4, sql: schemaV4},
}
func applyMigrations(ctx context.Context, db *sql.DB) error {
@@ -196,3 +198,17 @@ CREATE TABLE signal_intents (
) STRICT;
CREATE INDEX signal_intents_pending ON signal_intents(issue_uuid, command_revision, signal, acknowledged, created_at, request_uuid);
`
// schemaV3 records the accepted/executed-after-deadline contradiction on the
// command itself. It is separate from the public lifecycle: the eventual
// client-reported terminal result remains authoritative while operators can
// still see that acceptance or execution arrived after the queue deadline.
const schemaV3 = `
ALTER TABLE commands ADD COLUMN late_after_expiry INTEGER NOT NULL DEFAULT 0 CHECK(late_after_expiry IN (0,1));
`
// schemaV4 retains the structured client admission rejection so status and
// history do not have to reconstruct an error from a free-form event detail.
const schemaV4 = `
ALTER TABLE commands ADD COLUMN rejection BLOB;
`
+9 -3
View File
@@ -46,6 +46,8 @@ type CommandView struct {
OutputTruncated bool
OutputIncomplete bool
RetainedCompressedBytes uint64
LateAfterExpiry bool
Rejection []byte
ExecutionSpec []byte
WindowsIdentity []byte
}
@@ -182,7 +184,7 @@ func (store *Store) ListCommandViews(ctx context.Context, page CommandPage) ([]C
query := `SELECT issue_uuid, client_id, issue_time, server_receipt_time,
queue_expiry_time, terminal_time, lifecycle, revision, last_event_seq,
exit_code, output_truncated, output_incomplete, retained_compressed_bytes,
execution_spec, execution_spec_raw_bytes, windows_execution_identity
late_after_expiry, rejection, execution_spec, execution_spec_raw_bytes, windows_execution_identity
FROM commands WHERE client_id = ? AND issue_time <= ?`
args := []any{page.ClientID, page.SnapshotBoundary}
if !page.IncludeTerminal {
@@ -231,7 +233,7 @@ func (store *Store) GetCommandView(ctx context.Context, clientID string, issue d
query := `SELECT issue_uuid, client_id, issue_time, server_receipt_time,
queue_expiry_time, terminal_time, lifecycle, revision, last_event_seq,
exit_code, output_truncated, output_incomplete, retained_compressed_bytes,
execution_spec, execution_spec_raw_bytes, windows_execution_identity
late_after_expiry, rejection, execution_spec, execution_spec_raw_bytes, windows_execution_identity
FROM commands WHERE issue_uuid = ?`
args := []any{issue[:]}
if clientID != "" {
@@ -252,10 +254,12 @@ func scanCommandView(scanner interface{ Scan(...any) error }) (CommandView, erro
var expiry, terminal sql.NullInt64
var exit sql.NullInt64
var truncated, incomplete int
var lateAfterExpiry int
var rejection []byte
var rawBytes uint64
if err := scanner.Scan(&issue, &view.ClientID, &issueTime, &receipt, &expiry, &terminal,
&view.Lifecycle, &view.Revision, &view.LastEventSeq, &exit, &truncated, &incomplete,
&view.RetainedCompressedBytes, &stored, &rawBytes, &view.WindowsIdentity); err != nil {
&view.RetainedCompressedBytes, &lateAfterExpiry, &rejection, &stored, &rawBytes, &view.WindowsIdentity); err != nil {
return view, err
}
if len(issue) != 16 {
@@ -267,6 +271,8 @@ func scanCommandView(scanner interface{ Scan(...any) error }) (CommandView, erro
view.QueueExpiryTime = nullableTime(expiry)
view.TerminalTime = nullableTime(terminal)
view.OutputTruncated, view.OutputIncomplete = truncated == 1, incomplete == 1
view.LateAfterExpiry = lateAfterExpiry == 1
view.Rejection = append([]byte(nil), rejection...)
if exit.Valid {
if exit.Int64 < -1<<31 || exit.Int64 > 1<<31-1 {
return view, ErrInvalidSegmentRecord
+41 -14
View File
@@ -6,14 +6,18 @@ param(
[Parameter(Mandatory = $false)]
[ValidatePattern('^[a-z0-9][a-z0-9-]{0,63}$')]
[string] $RunId = '',
[string] $VmName = $(if ($env:RVBOX_WINDOWS_VM) { $env:RVBOX_WINDOWS_VM } else { 'rvbox-win10-test' }),
[string] $Snapshot = $(if ($env:RVBOX_WINDOWS_BASELINE_SNAPSHOT) { $env:RVBOX_WINDOWS_BASELINE_SNAPSHOT } else { 'baseline-disk-first' })
[string] $VmName = $(if ($env:RVBOX_WINDOWS_VM) { $env:RVBOX_WINDOWS_VM } elseif ($env:RVBOX_TEST_VBOX_VM) { $env:RVBOX_TEST_VBOX_VM } else { 'rvbox-win10-test' }),
[string] $Snapshot = $(if ($env:RVBOX_WINDOWS_BASELINE_SNAPSHOT) { $env:RVBOX_WINDOWS_BASELINE_SNAPSHOT } elseif ($env:RVBOX_TEST_VBOX_SNAPSHOT) { $env:RVBOX_TEST_VBOX_SNAPSHOT } else { 'baseline-disk-first' })
)
$ErrorActionPreference = 'Stop'
$VBoxManage = if ($env:VBOXMANAGE) { $env:VBOXMANAGE } else { 'VBoxManage' }
$GuestUser = $env:RVBOX_WINDOWS_GUEST_USER
$GuestPassword = $env:RVBOX_WINDOWS_GUEST_PASSWORD
$GuestUser = if ($env:RVBOX_WINDOWS_GUEST_USER) { $env:RVBOX_WINDOWS_GUEST_USER } else { $env:RVBOX_TEST_GUEST_USER }
$GuestPasswordFile = if ($env:RVBOX_WINDOWS_GUEST_PASSWORD_FILE) {
$env:RVBOX_WINDOWS_GUEST_PASSWORD_FILE
} else {
$env:RVBOX_TEST_GUEST_PASSWORD_FILE
}
$LeaseRoot = if ($env:RVBOX_WINDOWS_LEASE_DIR) { $env:RVBOX_WINDOWS_LEASE_DIR } else { Join-Path $PSScriptRoot '..\..\.test-runs\windows' }
function Invoke-VBox([string[]] $Arguments) {
@@ -45,10 +49,33 @@ function Release-Lease {
}
function Guest-Args([string[]] $Arguments) {
if ([string]::IsNullOrWhiteSpace($GuestUser) -or [string]::IsNullOrWhiteSpace($GuestPassword)) {
throw 'Set RVBOX_WINDOWS_GUEST_USER and RVBOX_WINDOWS_GUEST_PASSWORD in the host environment; secrets are never read from repository files or printed.'
if ([string]::IsNullOrWhiteSpace($GuestUser) -or [string]::IsNullOrWhiteSpace($GuestPasswordFile)) {
throw 'Set the guest user and password-file environment variables in the host environment; secrets are never read from repository files or printed.'
}
return @('guestcontrol', $VmName, '--username', $GuestUser, '--password', $GuestPassword) + $Arguments
if (-not (Test-Path -LiteralPath $GuestPasswordFile -PathType Leaf)) {
throw "Guest password file does not exist: $GuestPasswordFile"
}
return @('guestcontrol', $VmName, '--username', $GuestUser, '--passwordfile', $GuestPasswordFile) + $Arguments
}
function Wait-PowerOff {
for ($attempt = 0; $attempt -lt 60; $attempt++) {
$current = (Invoke-VBox @('showvminfo', $VmName, '--machinereadable') | Where-Object { $_ -like 'VMState=*' }) -replace '^VMState="?([^"\r\n]+)"?$', '$1'
if ($current -eq 'poweroff') { return }
Start-Sleep -Seconds 1
}
throw "VM did not power off within the bounded stop deadline: $VmName"
}
function Wait-GuestReady {
for ($attempt = 0; $attempt -lt 60; $attempt++) {
$properties = Invoke-VBox @('guestproperty', 'enumerate', $VmName)
if (($properties -match '/GuestAdd/Version') -and ($properties -match '/GuestInfo/OS/Release')) {
return
}
Start-Sleep -Seconds 1
}
throw "Guest Additions did not become ready within the bounded boot deadline: $VmName"
}
try {
@@ -62,18 +89,18 @@ try {
$state = (Invoke-VBox @('showvminfo', $VmName, '--machinereadable') | Where-Object { $_ -like 'VMState=*' }) -replace '^VMState="?([^"\r\n]+)"?$', '$1'
switch ($Action) {
'Prepare' {
if ($state -eq 'running') { Invoke-VBox (Guest-Args @('run', 'C:\Windows\System32\whoami.exe', '--', '/groups')) | Out-Null }
if ($state -eq 'running') { Wait-GuestReady }
else {
Invoke-VBox @('startvm', $VmName, '--type', 'headless') | Out-Null
Start-Sleep -Seconds 5
Invoke-VBox (Guest-Args @('run', 'C:\Windows\System32\whoami.exe', '--', '/groups')) | Out-Null
Wait-GuestReady
}
Invoke-VBox (Guest-Args @('run', 'C:\Windows\System32\query.exe', '--', 'user')) | Out-Null
Invoke-VBox (Guest-Args @('run', '--exe', 'C:\Windows\System32\whoami.exe', '--wait-stdout', '--wait-stderr', '--unquoted-args', '--', '/groups')) | Out-Null
Invoke-VBox (Guest-Args @('run', '--exe', 'C:\Windows\System32\query.exe', '--wait-stdout', '--wait-stderr', '--unquoted-args', '--', 'user')) | Out-Null
Write-Output "prepared VM=$VmName run=$RunId"
}
'Run' {
$guestBinary = if ($env:RVBOX_WINDOWS_GUEST_BINARY) { $env:RVBOX_WINDOWS_GUEST_BINARY } else { 'C:\ProgramData\RVBox\test\rvbox.exe' }
Invoke-VBox (Guest-Args @('run', $guestBinary, '--', '--check-config', '--config', 'C:\ProgramData\RVBox\client.toml')) | Out-Null
Invoke-VBox (Guest-Args @('run', '--exe', $guestBinary, '--wait-stdout', '--wait-stderr', '--unquoted-args', '--', '--check-config', '--config', 'C:\ProgramData\RVBox\client.toml')) | Out-Null
Write-Output "ran native Windows smoke run=$RunId"
}
'Collect' {
@@ -84,11 +111,11 @@ try {
Write-Output "collected native artifacts under $destination"
}
'Stop' {
if ($state -eq 'running') { Invoke-VBox @('controlvm', $VmName, 'acpipowerbutton') | Out-Null }
if ($state -eq 'running') { Invoke-VBox @('controlvm', $VmName, 'acpipowerbutton') | Out-Null; Wait-PowerOff }
Write-Output "requested graceful stop VM=$VmName"
}
'Reset' {
if ($state -eq 'running') { Invoke-VBox @('controlvm', $VmName, 'acpipowerbutton') | Out-Null; Start-Sleep -Seconds 3 }
if ($state -eq 'running') { Invoke-VBox @('controlvm', $VmName, 'acpipowerbutton') | Out-Null; Wait-PowerOff }
Invoke-VBox @('snapshot', $VmName, 'restore', $Snapshot) | Out-Null
Write-Output "restored baseline snapshot=$Snapshot VM=$VmName run=$RunId"
}
+18
View File
@@ -269,6 +269,12 @@ layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/supervisor_test.go:TestStartSpecValidation_BH_SUPERVISOR_01"]
[[requirements]]
id = "HP-SUPERVISOR-05"
layer = "unit"
status = "implemented"
tests = ["internal/client/supervisor/supervisor_test.go:TestWindowsIdentityMapsSelectionEvidence_HP_SUPERVISOR_05"]
[[requirements]]
id = "HP-SUPERVISOR-03"
layer = "unit"
@@ -404,6 +410,12 @@ layer = "integration"
status = "implemented"
tests = ["test/integration/clientagent/clientagent_integration_test.go:TestControlStdinIntentReplaysAndAcknowledges_HP_DISPATCH_08"]
[[requirements]]
id = "BH-DISPATCH-09"
layer = "unit"
status = "implemented"
tests = ["internal/server/store/command_test.go:TestLateDispatchAcceptanceAndEventRetainExpiryContradiction_BH_DISPATCH_09"]
[[requirements]]
id = "HP-SIGNAL-01"
layer = "integration"
@@ -551,6 +563,12 @@ layer = "unit"
status = "implemented"
tests = ["internal/client/spool/recovery_test.go:TestCheckSpoolRejectsCounterDrift_BH_CLIENT_03"]
[[requirements]]
id = "BH-CLIENT-13"
layer = "unit"
status = "implemented"
tests = ["internal/client/spool/spool_test.go:TestSendWindowPinsUnacknowledgedBytes_BH_CLIENT_13"]
[[requirements]]
id = "HP-WINCTX-02"
layer = "integration"
@@ -73,7 +73,7 @@ func TestRealSQLiteInitializationAndRestart_HP_STORE_01(t *testing.T) {
t.Fatalf("tables = %v, want %v", names, want)
}
var migrationCount int
if err := opened.DB().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil || migrationCount != 2 {
if err := opened.DB().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil || migrationCount != 4 {
t.Fatalf("migration count = %d, err = %v", migrationCount, err)
}
if err := opened.Close(); err != nil {
@@ -81,7 +81,7 @@ func TestRealSQLiteInitializationAndRestart_HP_STORE_01(t *testing.T) {
}
reopened := openStore(t, dataDir)
if err := reopened.DB().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil || migrationCount != 2 {
if err := reopened.DB().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil || migrationCount != 4 {
t.Fatalf("reopened migration count = %d, err = %v", migrationCount, err)
}
if err := reopened.Close(); err != nil {