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