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