// Package protocol defines vev's typed, transport-neutral client/daemon // contract. Binary framing and codecs live outside this package. package protocol import ( "errors" "fmt" "math" "time" renderer "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/domain/terminalcap" ) // Version is the negotiated vev session protocol version. It carries the // daemonmux physical preamble conversation (multiplex.proto), whose // negotiated ceilings and accepted daemon binding ride the same wire // version as the session and broker conversations. const Version uint16 = 67 // HandshakeTimeout bounds every transport handshake from connect through the // first committed publication. It excludes the preceding client-local // warm-reuse probe, which has its own short budget: a cold dial that follows // a warm miss still receives the full handshake budget. const HandshakeTimeout = 15 * time.Second // MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest // valid ViewContext (publication, lifecycle, session name, ephemeral flag, tab // and pane IDs, including string lengths) within the 16 MiB frame limit. const MaxOutputDataLen = (16 << 20) - 1 - 55 - (8 + 16 + 2 + 64 + 1 + 2 + 128 + 2 + 128) // MaxOutputWindow caps output states sent before client acknowledgement. const MaxOutputWindow = 8 // MaxOutputWindowBytes caps the Output data a daemon keeps unacknowledged per // attachment, state frames and side effects together. A full window of large // frames would otherwise overrun every bounded buffer between daemon and // terminal. The daemon may always send one frame when none is unacknowledged, // so a single frame up to MaxOutputDataLen still makes progress. const MaxOutputWindowBytes = 2 << 20 // ConnectionCapabilities describes transport behavior relevant to session // flow without naming a concrete carriage. type ConnectionCapabilities struct { OutputDataLimit int PreferredOutputWindow uint8 AsyncSend bool OwnedSynchronousSend bool LinkState bool } var ( ErrInvalidSize = errors.New("ports: invalid terminal size") ErrInvalidHello = errors.New("ports: invalid hello") ErrInvalidOutput = errors.New("ports: invalid output") ErrInvalidAck = errors.New("ports: invalid ack") ErrInvalidAttachTarget = errors.New("ports: invalid attach target") ErrInvalidNavigation = errors.New("ports: invalid navigation") ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy") ErrInvalidClientNotice = errors.New("ports: invalid client notice") ErrInvalidTerminalFocus = errors.New("ports: invalid terminal focus") ErrInvalidDetached = errors.New("ports: invalid detached reason") ) const ( IntentEphemeral uint8 = 0 IntentNew uint8 = 1 IntentAttach uint8 = 2 IntentResume uint8 = 3 ) type EnvironmentPolicy uint8 const ( EnvironmentPolicyClientOwned EnvironmentPolicy = iota EnvironmentPolicyDaemonOwned ) const ( CapabilityResume uint32 = 1 << 0 CapabilityUDP uint32 = 1 << 1 CapabilityPredict uint32 = 1 << 2 ) const ( ErrVersionMismatch uint16 = 1 ErrNoSuchSession uint16 = 2 ErrNameTaken uint16 = 3 ErrServerShutdown uint16 = 4 ErrInvalidSessionName uint16 = 5 ErrUnknownCommand uint16 = 6 ErrNotScriptable uint16 = 7 ErrInvalidCommandArgs uint16 = 8 ErrNoSuchTarget uint16 = 9 ErrAmbiguousTarget uint16 = 10 ErrInternal uint16 = 255 ) const ( ReasonDetach uint8 = 0 ReasonSessionKilled uint8 = 1 ReasonServerShutdown uint8 = 2 ReasonReplaced uint8 = 3 // ReasonDetachToPicker transfers terminal ownership to the client picker. ReasonDetachToPicker uint8 = 4 ) // Hello is sent by the client immediately after connecting. type Hello struct { Version uint16 Intent uint8 ClientID [16]byte ResumeToken uint64 Name string Size domain.Size PixelWidth int PixelHeight int TermEnv string Cwd string // Color is the color output capability the client claims for this // attachment. It travels only in Hello; Theme carries no capability. Color terminalcap.ColorCapabilities // KittyDirectGraphics is an explicit declaration that the client has // probed its direct outer terminal and it accepts Kitty graphics output. KittyDirectGraphics bool // KittyKeyboard declares that the client enabled the kitty keyboard // protocol on its outer terminal, so input carries kitty key sequences. KittyKeyboard bool MaxOutputInFlight uint8 Env []string SessionTarget *SessionAttachTarget ExactTarget *ExactSessionTarget PreferredTabID domain.TabStableID EnvironmentPolicy EnvironmentPolicy NavigationCapabilities NavigationCapabilities Remote bool // TerminalFocus is the client's terminal window focus when it attaches, // so the attachment's first paint already knows whether it may be seen. TerminalFocus domain.TerminalFocus // ClientPID is the local client process, exposed to bar scripts. It is 0 // for remote attaches, which run on another machine. ClientPID uint32 } func (h Hello) Geometry() domain.Geometry { return domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}.NormalizePixels() } type Input struct { InputSeq uint64 ActionID uint64 Data []byte } type Resize struct { Size domain.Size PixelWidth int PixelHeight int } func (m Resize) Geometry() domain.Geometry { return domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}.NormalizePixels() } type Theme struct { HasForeground bool Foreground renderer.RGB HasBackground bool Background renderer.RGB SchemeKnown bool Light bool PaletteKnown uint16 Palette [16]renderer.RGB } type ImagePush struct { InputSeq uint64 Mime string Data []byte } type ClientNotice struct{ Action uint8 } const ( ClientNoticeClipboardFallback uint8 = 1 ClientNoticeClipboardTooLarge uint8 = 2 ClientNoticeLinkDegraded uint8 = 3 ClientNoticeLinkConnected uint8 = 4 ) // Detach releases the attachment. Closed reports that the client process is // ending, so the daemon applies the ephemeral close-on-exit policy. type Detach struct { Closed bool } type Ping struct{} type Pong struct{} type Ack struct { Epoch uint64 State uint64 } type Welcome struct { SessionID string SessionName string Ephemeral bool ResumeToken uint64 Capabilities uint32 CommittedIdentity *CommittedRouteIdentity } type ErrorMsg struct { Code uint16 Text string } type ViewContext struct { Publication uint64 Route CommittedRouteIdentity TabID domain.TabStableID FocusedPaneID domain.PaneStableID } func (c ViewContext) Validate() error { if c.Publication == 0 { return errors.New("invalid view publication") } if err := c.Route.Validate(); err != nil { return err } if err := domain.ValidateTabStableID(c.TabID); err != nil { return err } return domain.ValidatePaneStableID(c.FocusedPaneID) } // EchoAckDelay is how long sequenced input must have been applied before the // daemon acknowledges it in Output.Echo. Like mosh's ECHO_TIMEOUT, it gives the // PTY time to echo the input, so the screen published with the acknowledgement // already contains that echo. Clients remove it from round-trip samples. const EchoAckDelay = 50 * time.Millisecond type Output struct { Epoch uint64 Base uint64 New uint64 Echo uint64 ViewRevision uint64 Size domain.Size Full bool Context *ViewContext Data []byte } type UIFence struct{ ActionID uint64 } type UIReceiptOutcome uint8 const ( UIReceiptProcessed UIReceiptOutcome = iota + 1 UIReceiptUnavailable ) type UIReceipt struct { ActionID uint64 Epoch uint64 State uint64 ViewPublication uint64 Outcome UIReceiptOutcome } // Validate requires a committed publication for success. Unavailable receipts // carry no boundary: expiry cannot claim that any captured view was processed. func (r UIReceipt) Validate() error { if r.ActionID == 0 { return errors.New("UI receipt requires an action ID") } switch r.Outcome { case UIReceiptProcessed: if r.Epoch != 0 && r.State != 0 && r.ViewPublication != 0 { return nil } case UIReceiptUnavailable: if r.Epoch == 0 && r.State == 0 && r.ViewPublication == 0 { return nil } } return errors.New("invalid UI receipt boundary or outcome") } type UIViewUpdate struct { Epoch uint64 State uint64 Context ViewContext } type AttachTarget struct { CauseActionID uint64 RequestID uint64 Endpoint string Session string Intent uint8 SessionTarget *SessionAttachTarget ExactTarget *ExactSessionTarget EnvironmentPolicy EnvironmentPolicy SamePeer bool // PreferredTabID carries an explicit local tab selection across a handoff. // Empty retains the client's route memory; a disappeared tab uses Hello's // existing default-tab fallback. PreferredTabID domain.TabStableID } type Detached struct{ Reason uint8 } type List struct{} type KillScope uint8 const ( KillSession KillScope = 0 // KillDaemon retains the legacy All=true wire value so an older client can // never turn daemon shutdown into destructive session deletion. KillDaemon KillScope = 1 KillAll KillScope = 2 ) type Kill struct { RequestID uint64 Name string Scope KillScope } type KillOutcome uint8 const ( KillSucceeded KillOutcome = iota + 1 KillFailed KillOutcomeUnknown ) type KillFailure struct { Class string Name string Text string } type KillResult struct { RequestID uint64 Outcome KillOutcome Code uint16 Text string Failures []KillFailure } type SessionState uint8 const ( SessionUp SessionState = iota SessionDown SessionBroken ) type SessionInfo struct { SessionID string Name string State SessionState Ephemeral bool Tabs uint16 Attached bool } type Sessions struct{ Sessions []SessionInfo } type OutputResetRequest struct{} // SelectTab asks the daemon to show one exact tab of the attached session on // this attachment's own view. It is the client picker's in-place tab switch: // the attachment and its stream stay live, and a tab that no longer exists is // refused without changing the view. type SelectTab struct { TabID domain.TabStableID } // Validate requires a well-formed stable tab identity. func (m SelectTab) Validate() error { return domain.ValidateTabStableID(m.TabID) } // TerminalFocus reports that the client's terminal window gained or lost // focus. A client sends it only after its terminal reports focus, so an // attachment that never sends it keeps unknown focus. type TerminalFocus struct { Focus domain.TerminalFocus } // Validate accepts only a reported state: unknown is the absence of a report. func (m TerminalFocus) Validate() error { if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused { return ErrInvalidTerminalFocus } return nil } // ModifierState reports the modifier keys held on their own in the client's // terminal. A client sends it only when its terminal reports modifier key // events, so an attachment that never sends it shows no modifier held. type ModifierState struct { Ctrl bool } func validEnvironmentPolicy(policy EnvironmentPolicy) bool { return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned } func ValidateEnvironmentPolicy(policy EnvironmentPolicy) error { if !validEnvironmentPolicy(policy) { return ErrInvalidEnvironmentPolicy } return nil } func validClientNoticeAction(action uint8) bool { switch action { case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected: return true default: return false } } func validDetachedReason(reason uint8) bool { switch reason { case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced, ReasonDetachToPicker: return true default: return false } } func ValidateClientNotice(m ClientNotice) error { if !validClientNoticeAction(m.Action) { return ErrInvalidClientNotice } return nil } func ValidateDetached(m Detached) error { if !validDetachedReason(m.Reason) { return ErrInvalidDetached } return nil } func ValidateHello(h Hello) error { if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume { return ErrInvalidHello } if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil { return fmt.Errorf("%w: geometry", ErrInvalidHello) } if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) { return ErrInvalidHello } if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil { return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err) } if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 { return ErrInvalidHello } for _, entry := range h.Env { if uint64(len(entry)) > math.MaxUint32 { return ErrInvalidHello } } if h.PreferredTabID != "" { if h.Intent != IntentAttach && h.Intent != IntentResume { return ErrInvalidHello } if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil { return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err) } } if h.ExactTarget != nil { if h.Intent != IntentAttach && h.Intent != IntentResume { return ErrInvalidHello } if err := h.ExactTarget.Validate(); err != nil { return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err) } if h.Name != h.ExactTarget.SessionName { return ErrInvalidHello } if h.SessionTarget != nil && (h.SessionTarget.LifecycleID != h.ExactTarget.LifecycleID || h.SessionTarget.SessionName != h.ExactTarget.SessionName) { return ErrInvalidHello } } if h.SessionTarget == nil { return nil } if h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || (h.Intent != IntentAttach && h.Intent != IntentResume) { return ErrInvalidHello } if err := h.SessionTarget.Validate(); err != nil { return fmt.Errorf("%w: session target: %v", ErrInvalidHello, err) } if h.Name != h.SessionTarget.SessionName { return ErrInvalidHello } return nil } func ValidateOutput(m Output) error { if m.Epoch == 0 { return ErrInvalidOutput } if m.New == 0 { if m.Base != 0 || m.Full || m.Context != nil { return ErrInvalidOutput } } else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) { return ErrInvalidOutput } if err := ValidateSize(m.Size); err != nil { return fmt.Errorf("%w: size", ErrInvalidOutput) } if m.Context != nil { if err := m.Context.Validate(); err != nil { return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err) } } if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen { return ErrInvalidOutput } return nil } func ValidateAck(m Ack) error { if m.Epoch == 0 { return ErrInvalidAck } return nil } func ValidateAttachTarget(m AttachTarget) error { if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { return ErrInvalidAttachTarget } if err := domain.ValidateSessionName(m.Session); err != nil { return ErrInvalidAttachTarget } if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume { return ErrInvalidAttachTarget } if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) { return ErrInvalidAttachTarget } if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew { return ErrInvalidAttachTarget } if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) { return ErrInvalidAttachTarget } if m.SamePeer && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil) { return ErrInvalidAttachTarget } if m.PreferredTabID != "" && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) { return ErrInvalidAttachTarget } if m.SessionTarget == nil { return nil } if m.Endpoint != "" || m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || m.Intent != IntentAttach { return ErrInvalidAttachTarget } if err := m.SessionTarget.Validate(); err != nil { return fmt.Errorf("%w: session target: %v", ErrInvalidAttachTarget, err) } if m.Session != m.SessionTarget.SessionName { return ErrInvalidAttachTarget } return nil }