1// Package protocol defines vev's typed, transport-neutral client/daemon
2// contract. Binary framing and codecs live outside this package.
3package protocol
4
5import (
6 "errors"
7 "fmt"
8 "math"
9 "time"
10
11 renderer "github.com/bnema/vev-vt"
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/domain/terminalcap"
14)
15
16// Version is the negotiated vev session protocol version. It carries the
17// daemonmux physical preamble conversation (multiplex.proto), whose
18// negotiated ceilings and accepted daemon binding ride the same wire
19// version as the session and broker conversations.
20const Version uint16 = 67
21
22// HandshakeTimeout bounds every transport handshake from connect through the
23// first committed publication. It excludes the preceding client-local
24// warm-reuse probe, which has its own short budget: a cold dial that follows
25// a warm miss still receives the full handshake budget.
26const HandshakeTimeout = 15 * time.Second
27
28// MaxOutputDataLen reserves the type byte, fixed Output fields, and the largest
29// valid ViewContext (publication, lifecycle, session name, ephemeral flag, tab
30// and pane IDs, including string lengths) within the 16 MiB frame limit.
31const MaxOutputDataLen = (16 << 20) - 1 - 55 - (8 + 16 + 2 + 64 + 1 + 2 + 128 + 2 + 128)
32
33// MaxOutputWindow caps output states sent before client acknowledgement.
34const MaxOutputWindow = 8
35
36// MaxOutputWindowBytes caps the Output data a daemon keeps unacknowledged per
37// attachment, state frames and side effects together. A full window of large
38// frames would otherwise overrun every bounded buffer between daemon and
39// terminal. The daemon may always send one frame when none is unacknowledged,
40// so a single frame up to MaxOutputDataLen still makes progress.
41const MaxOutputWindowBytes = 2 << 20
42
43// ConnectionCapabilities describes transport behavior relevant to session
44// flow without naming a concrete carriage.
45type ConnectionCapabilities struct {
46 OutputDataLimit int
47 PreferredOutputWindow uint8
48 AsyncSend bool
49 OwnedSynchronousSend bool
50 LinkState bool
51}
52
53var (
54 ErrInvalidSize = errors.New("ports: invalid terminal size")
55 ErrInvalidHello = errors.New("ports: invalid hello")
56 ErrInvalidOutput = errors.New("ports: invalid output")
57 ErrInvalidAck = errors.New("ports: invalid ack")
58 ErrInvalidAttachTarget = errors.New("ports: invalid attach target")
59 ErrInvalidNavigation = errors.New("ports: invalid navigation")
60 ErrInvalidEnvironmentPolicy = errors.New("ports: invalid environment policy")
61 ErrInvalidClientNotice = errors.New("ports: invalid client notice")
62 ErrInvalidTerminalFocus = errors.New("ports: invalid terminal focus")
63 ErrInvalidDetached = errors.New("ports: invalid detached reason")
64)
65
66const (
67 IntentEphemeral uint8 = 0
68 IntentNew uint8 = 1
69 IntentAttach uint8 = 2
70 IntentResume uint8 = 3
71)
72
73type EnvironmentPolicy uint8
74
75const (
76 EnvironmentPolicyClientOwned EnvironmentPolicy = iota
77 EnvironmentPolicyDaemonOwned
78)
79
80const (
81 CapabilityResume uint32 = 1 << 0
82 CapabilityUDP uint32 = 1 << 1
83 CapabilityPredict uint32 = 1 << 2
84)
85
86const (
87 ErrVersionMismatch uint16 = 1
88 ErrNoSuchSession uint16 = 2
89 ErrNameTaken uint16 = 3
90 ErrServerShutdown uint16 = 4
91 ErrInvalidSessionName uint16 = 5
92 ErrUnknownCommand uint16 = 6
93 ErrNotScriptable uint16 = 7
94 ErrInvalidCommandArgs uint16 = 8
95 ErrNoSuchTarget uint16 = 9
96 ErrAmbiguousTarget uint16 = 10
97 ErrInternal uint16 = 255
98)
99
100const (
101 ReasonDetach uint8 = 0
102 ReasonSessionKilled uint8 = 1
103 ReasonServerShutdown uint8 = 2
104 ReasonReplaced uint8 = 3
105 // ReasonDetachToPicker transfers terminal ownership to the client picker.
106 ReasonDetachToPicker uint8 = 4
107)
108
109// Hello is sent by the client immediately after connecting.
110type Hello struct {
111 Version uint16
112 Intent uint8
113 ClientID [16]byte
114 ResumeToken uint64
115 Name string
116 Size domain.Size
117 PixelWidth int
118 PixelHeight int
119 TermEnv string
120 Cwd string
121 // Color is the color output capability the client claims for this
122 // attachment. It travels only in Hello; Theme carries no capability.
123 Color terminalcap.ColorCapabilities
124 // KittyDirectGraphics is an explicit declaration that the client has
125 // probed its direct outer terminal and it accepts Kitty graphics output.
126 KittyDirectGraphics bool
127 // KittyKeyboard declares that the client enabled the kitty keyboard
128 // protocol on its outer terminal, so input carries kitty key sequences.
129 KittyKeyboard bool
130 MaxOutputInFlight uint8
131 Env []string
132 SessionTarget *SessionAttachTarget
133 ExactTarget *ExactSessionTarget
134 PreferredTabID domain.TabStableID
135 EnvironmentPolicy EnvironmentPolicy
136 NavigationCapabilities NavigationCapabilities
137 Remote bool
138 // TerminalFocus is the client's terminal window focus when it attaches,
139 // so the attachment's first paint already knows whether it may be seen.
140 TerminalFocus domain.TerminalFocus
141 // ClientPID is the local client process, exposed to bar scripts. It is 0
142 // for remote attaches, which run on another machine.
143 ClientPID uint32
144}
145
146func (h Hello) Geometry() domain.Geometry {
147 return domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}.NormalizePixels()
148}
149
150type Input struct {
151 InputSeq uint64
152 ActionID uint64
153 Data []byte
154}
155
156type Resize struct {
157 Size domain.Size
158 PixelWidth int
159 PixelHeight int
160}
161
162func (m Resize) Geometry() domain.Geometry {
163 return domain.Geometry{Size: m.Size, PixelWidth: m.PixelWidth, PixelHeight: m.PixelHeight}.NormalizePixels()
164}
165
166type Theme struct {
167 HasForeground bool
168 Foreground renderer.RGB
169 HasBackground bool
170 Background renderer.RGB
171 SchemeKnown bool
172 Light bool
173 PaletteKnown uint16
174 Palette [16]renderer.RGB
175}
176
177type ImagePush struct {
178 InputSeq uint64
179 Mime string
180 Data []byte
181}
182
183type ClientNotice struct{ Action uint8 }
184
185const (
186 ClientNoticeClipboardFallback uint8 = 1
187 ClientNoticeClipboardTooLarge uint8 = 2
188 ClientNoticeLinkDegraded uint8 = 3
189 ClientNoticeLinkConnected uint8 = 4
190)
191
192// Detach releases the attachment. Closed reports that the client process is
193// ending, so the daemon applies the ephemeral close-on-exit policy.
194type Detach struct {
195 Closed bool
196}
197type Ping struct{}
198type Pong struct{}
199
200type Ack struct {
201 Epoch uint64
202 State uint64
203}
204
205type Welcome struct {
206 SessionID string
207 SessionName string
208 Ephemeral bool
209 ResumeToken uint64
210 Capabilities uint32
211 CommittedIdentity *CommittedRouteIdentity
212}
213
214type ErrorMsg struct {
215 Code uint16
216 Text string
217}
218
219type ViewContext struct {
220 Publication uint64
221 Route CommittedRouteIdentity
222 TabID domain.TabStableID
223 FocusedPaneID domain.PaneStableID
224}
225
226func (c ViewContext) Validate() error {
227 if c.Publication == 0 {
228 return errors.New("invalid view publication")
229 }
230 if err := c.Route.Validate(); err != nil {
231 return err
232 }
233 if err := domain.ValidateTabStableID(c.TabID); err != nil {
234 return err
235 }
236 return domain.ValidatePaneStableID(c.FocusedPaneID)
237}
238
239// EchoAckDelay is how long sequenced input must have been applied before the
240// daemon acknowledges it in Output.Echo. Like mosh's ECHO_TIMEOUT, it gives the
241// PTY time to echo the input, so the screen published with the acknowledgement
242// already contains that echo. Clients remove it from round-trip samples.
243const EchoAckDelay = 50 * time.Millisecond
244
245type Output struct {
246 Epoch uint64
247 Base uint64
248 New uint64
249 Echo uint64
250 ViewRevision uint64
251 Size domain.Size
252 Full bool
253 Context *ViewContext
254 Data []byte
255}
256
257type UIFence struct{ ActionID uint64 }
258
259type UIReceiptOutcome uint8
260
261const (
262 UIReceiptProcessed UIReceiptOutcome = iota + 1
263 UIReceiptUnavailable
264)
265
266type UIReceipt struct {
267 ActionID uint64
268 Epoch uint64
269 State uint64
270 ViewPublication uint64
271 Outcome UIReceiptOutcome
272}
273
274// Validate requires a committed publication for success. Unavailable receipts
275// carry no boundary: expiry cannot claim that any captured view was processed.
276func (r UIReceipt) Validate() error {
277 if r.ActionID == 0 {
278 return errors.New("UI receipt requires an action ID")
279 }
280 switch r.Outcome {
281 case UIReceiptProcessed:
282 if r.Epoch != 0 && r.State != 0 && r.ViewPublication != 0 {
283 return nil
284 }
285 case UIReceiptUnavailable:
286 if r.Epoch == 0 && r.State == 0 && r.ViewPublication == 0 {
287 return nil
288 }
289 }
290 return errors.New("invalid UI receipt boundary or outcome")
291}
292
293type UIViewUpdate struct {
294 Epoch uint64
295 State uint64
296 Context ViewContext
297}
298
299type AttachTarget struct {
300 CauseActionID uint64
301 RequestID uint64
302 Endpoint string
303 Session string
304 Intent uint8
305 SessionTarget *SessionAttachTarget
306 ExactTarget *ExactSessionTarget
307 EnvironmentPolicy EnvironmentPolicy
308 SamePeer bool
309 // PreferredTabID carries an explicit local tab selection across a handoff.
310 // Empty retains the client's route memory; a disappeared tab uses Hello's
311 // existing default-tab fallback.
312 PreferredTabID domain.TabStableID
313}
314
315type Detached struct{ Reason uint8 }
316type List struct{}
317
318type KillScope uint8
319
320const (
321 KillSession KillScope = 0
322 // KillDaemon retains the legacy All=true wire value so an older client can
323 // never turn daemon shutdown into destructive session deletion.
324 KillDaemon KillScope = 1
325 KillAll KillScope = 2
326)
327
328type Kill struct {
329 RequestID uint64
330 Name string
331 Scope KillScope
332}
333
334type KillOutcome uint8
335
336const (
337 KillSucceeded KillOutcome = iota + 1
338 KillFailed
339 KillOutcomeUnknown
340)
341
342type KillFailure struct {
343 Class string
344 Name string
345 Text string
346}
347
348type KillResult struct {
349 RequestID uint64
350 Outcome KillOutcome
351 Code uint16
352 Text string
353 Failures []KillFailure
354}
355
356type SessionState uint8
357
358const (
359 SessionUp SessionState = iota
360 SessionDown
361 SessionBroken
362)
363
364type SessionInfo struct {
365 SessionID string
366 Name string
367 State SessionState
368 Ephemeral bool
369 Tabs uint16
370 Attached bool
371}
372
373type Sessions struct{ Sessions []SessionInfo }
374type OutputResetRequest struct{}
375
376// SelectTab asks the daemon to show one exact tab of the attached session on
377// this attachment's own view. It is the client picker's in-place tab switch:
378// the attachment and its stream stay live, and a tab that no longer exists is
379// refused without changing the view.
380type SelectTab struct {
381 TabID domain.TabStableID
382}
383
384// Validate requires a well-formed stable tab identity.
385func (m SelectTab) Validate() error {
386 return domain.ValidateTabStableID(m.TabID)
387}
388
389// TerminalFocus reports that the client's terminal window gained or lost
390// focus. A client sends it only after its terminal reports focus, so an
391// attachment that never sends it keeps unknown focus.
392type TerminalFocus struct {
393 Focus domain.TerminalFocus
394}
395
396// Validate accepts only a reported state: unknown is the absence of a report.
397func (m TerminalFocus) Validate() error {
398 if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused {
399 return ErrInvalidTerminalFocus
400 }
401 return nil
402}
403
404// ModifierState reports the modifier keys held on their own in the client's
405// terminal. A client sends it only when its terminal reports modifier key
406// events, so an attachment that never sends it shows no modifier held.
407type ModifierState struct {
408 Ctrl bool
409}
410
411func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
412 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
413}
414
415func ValidateEnvironmentPolicy(policy EnvironmentPolicy) error {
416 if !validEnvironmentPolicy(policy) {
417 return ErrInvalidEnvironmentPolicy
418 }
419 return nil
420}
421
422func validClientNoticeAction(action uint8) bool {
423 switch action {
424 case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
425 return true
426 default:
427 return false
428 }
429}
430
431func validDetachedReason(reason uint8) bool {
432 switch reason {
433 case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced, ReasonDetachToPicker:
434 return true
435 default:
436 return false
437 }
438}
439
440func ValidateClientNotice(m ClientNotice) error {
441 if !validClientNoticeAction(m.Action) {
442 return ErrInvalidClientNotice
443 }
444 return nil
445}
446
447func ValidateDetached(m Detached) error {
448 if !validDetachedReason(m.Reason) {
449 return ErrInvalidDetached
450 }
451 return nil
452}
453
454func ValidateHello(h Hello) error {
455 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
456 return ErrInvalidHello
457 }
458 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
459 return fmt.Errorf("%w: geometry", ErrInvalidHello)
460 }
461 if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) {
462 return ErrInvalidHello
463 }
464 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
465 return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
466 }
467 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 {
468 return ErrInvalidHello
469 }
470 for _, entry := range h.Env {
471 if uint64(len(entry)) > math.MaxUint32 {
472 return ErrInvalidHello
473 }
474 }
475 if h.PreferredTabID != "" {
476 if h.Intent != IntentAttach && h.Intent != IntentResume {
477 return ErrInvalidHello
478 }
479 if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
480 return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
481 }
482 }
483 if h.ExactTarget != nil {
484 if h.Intent != IntentAttach && h.Intent != IntentResume {
485 return ErrInvalidHello
486 }
487 if err := h.ExactTarget.Validate(); err != nil {
488 return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
489 }
490 if h.Name != h.ExactTarget.SessionName {
491 return ErrInvalidHello
492 }
493 if h.SessionTarget != nil && (h.SessionTarget.LifecycleID != h.ExactTarget.LifecycleID || h.SessionTarget.SessionName != h.ExactTarget.SessionName) {
494 return ErrInvalidHello
495 }
496 }
497 if h.SessionTarget == nil {
498 return nil
499 }
500 if h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || (h.Intent != IntentAttach && h.Intent != IntentResume) {
501 return ErrInvalidHello
502 }
503 if err := h.SessionTarget.Validate(); err != nil {
504 return fmt.Errorf("%w: session target: %v", ErrInvalidHello, err)
505 }
506 if h.Name != h.SessionTarget.SessionName {
507 return ErrInvalidHello
508 }
509 return nil
510}
511
512func ValidateOutput(m Output) error {
513 if m.Epoch == 0 {
514 return ErrInvalidOutput
515 }
516 if m.New == 0 {
517 if m.Base != 0 || m.Full || m.Context != nil {
518 return ErrInvalidOutput
519 }
520 } else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
521 return ErrInvalidOutput
522 }
523 if err := ValidateSize(m.Size); err != nil {
524 return fmt.Errorf("%w: size", ErrInvalidOutput)
525 }
526 if m.Context != nil {
527 if err := m.Context.Validate(); err != nil {
528 return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err)
529 }
530 }
531 if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
532 return ErrInvalidOutput
533 }
534 return nil
535}
536
537func ValidateAck(m Ack) error {
538 if m.Epoch == 0 {
539 return ErrInvalidAck
540 }
541 return nil
542}
543
544func ValidateAttachTarget(m AttachTarget) error {
545 if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
546 return ErrInvalidAttachTarget
547 }
548 if err := domain.ValidateSessionName(m.Session); err != nil {
549 return ErrInvalidAttachTarget
550 }
551 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
552 return ErrInvalidAttachTarget
553 }
554 if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {
555 return ErrInvalidAttachTarget
556 }
557 if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew {
558 return ErrInvalidAttachTarget
559 }
560 if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
561 return ErrInvalidAttachTarget
562 }
563 if m.SamePeer && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil) {
564 return ErrInvalidAttachTarget
565 }
566 if m.PreferredTabID != "" && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) {
567 return ErrInvalidAttachTarget
568 }
569 if m.SessionTarget == nil {
570 return nil
571 }
572 if m.Endpoint != "" || m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || m.Intent != IntentAttach {
573 return ErrInvalidAttachTarget
574 }
575 if err := m.SessionTarget.Validate(); err != nil {
576 return fmt.Errorf("%w: session target: %v", ErrInvalidAttachTarget, err)
577 }
578 if m.Session != m.SessionTarget.SessionName {
579 return ErrInvalidAttachTarget
580 }
581 return nil
582}