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
232type Output struct {
233 Epoch uint64
234 Base uint64
235 New uint64
236 Echo uint64
237 ViewRevision uint64
238 Size domain.Size
239 Full bool
240 Context *ViewContext
241 Data []byte
242}
243
244type UIFence struct{ ActionID uint64 }
245
246type UIReceiptOutcome uint8
247
248const (
249 UIReceiptProcessed UIReceiptOutcome = iota + 1
250 UIReceiptUnavailable
251)
252
253type UIReceipt struct {
254 ActionID uint64
255 Epoch uint64
256 State uint64
257 ViewPublication uint64
258 Outcome UIReceiptOutcome
259}
260
261// Validate requires a committed publication for success. Unavailable receipts
262// carry no boundary: expiry cannot claim that any captured view was processed.
263func (r UIReceipt) Validate() error {
264 if r.ActionID == 0 {
265 return errors.New("UI receipt requires an action ID")
266 }
267 switch r.Outcome {
268 case UIReceiptProcessed:
269 if r.Epoch != 0 && r.State != 0 && r.ViewPublication != 0 {
270 return nil
271 }
272 case UIReceiptUnavailable:
273 if r.Epoch == 0 && r.State == 0 && r.ViewPublication == 0 {
274 return nil
275 }
276 }
277 return errors.New("invalid UI receipt boundary or outcome")
278}
279
280type UIViewUpdate struct {
281 Epoch uint64
282 State uint64
283 Context ViewContext
284}
285
286type AttachTarget struct {
287 CauseActionID uint64
288 RequestID uint64
289 Endpoint string
290 Session string
291 Intent uint8
292 SessionTarget *SessionAttachTarget
293 ExactTarget *ExactSessionTarget
294 EnvironmentPolicy EnvironmentPolicy
295 SamePeer bool
296 // PreferredTabID carries an explicit local tab selection across a handoff.
297 // Empty retains the client's route memory; a disappeared tab uses Hello's
298 // existing default-tab fallback.
299 PreferredTabID domain.TabStableID
300}
301
302type Detached struct{ Reason uint8 }
303type List struct{}
304
305type KillScope uint8
306
307const (
308 KillSession KillScope = 0
309 // KillDaemon retains the legacy All=true wire value so an older client can
310 // never turn daemon shutdown into destructive session deletion.
311 KillDaemon KillScope = 1
312 KillAll KillScope = 2
313)
314
315type Kill struct {
316 RequestID uint64
317 Name string
318 Scope KillScope
319}
320
321type KillOutcome uint8
322
323const (
324 KillSucceeded KillOutcome = iota + 1
325 KillFailed
326 KillOutcomeUnknown
327)
328
329type KillFailure struct {
330 Class string
331 Name string
332 Text string
333}
334
335type KillResult struct {
336 RequestID uint64
337 Outcome KillOutcome
338 Code uint16
339 Text string
340 Failures []KillFailure
341}
342
343type SessionState uint8
344
345const (
346 SessionUp SessionState = iota
347 SessionDown
348 SessionBroken
349)
350
351type SessionInfo struct {
352 SessionID string
353 Name string
354 State SessionState
355 Ephemeral bool
356 Tabs uint16
357 Attached bool
358}
359
360type Sessions struct{ Sessions []SessionInfo }
361type OutputResetRequest struct{}
362
363// SelectTab asks the daemon to show one exact tab of the attached session on
364// this attachment's own view. It is the client picker's in-place tab switch:
365// the attachment and its stream stay live, and a tab that no longer exists is
366// refused without changing the view.
367type SelectTab struct {
368 TabID domain.TabStableID
369}
370
371// Validate requires a well-formed stable tab identity.
372func (m SelectTab) Validate() error {
373 return domain.ValidateTabStableID(m.TabID)
374}
375
376// TerminalFocus reports that the client's terminal window gained or lost
377// focus. A client sends it only after its terminal reports focus, so an
378// attachment that never sends it keeps unknown focus.
379type TerminalFocus struct {
380 Focus domain.TerminalFocus
381}
382
383// Validate accepts only a reported state: unknown is the absence of a report.
384func (m TerminalFocus) Validate() error {
385 if m.Focus != domain.TerminalFocusFocused && m.Focus != domain.TerminalFocusUnfocused {
386 return ErrInvalidTerminalFocus
387 }
388 return nil
389}
390
391func validEnvironmentPolicy(policy EnvironmentPolicy) bool {
392 return policy == EnvironmentPolicyClientOwned || policy == EnvironmentPolicyDaemonOwned
393}
394
395func ValidateEnvironmentPolicy(policy EnvironmentPolicy) error {
396 if !validEnvironmentPolicy(policy) {
397 return ErrInvalidEnvironmentPolicy
398 }
399 return nil
400}
401
402func validClientNoticeAction(action uint8) bool {
403 switch action {
404 case ClientNoticeClipboardFallback, ClientNoticeClipboardTooLarge, ClientNoticeLinkDegraded, ClientNoticeLinkConnected:
405 return true
406 default:
407 return false
408 }
409}
410
411func validDetachedReason(reason uint8) bool {
412 switch reason {
413 case ReasonDetach, ReasonSessionKilled, ReasonServerShutdown, ReasonReplaced, ReasonDetachToPicker:
414 return true
415 default:
416 return false
417 }
418}
419
420func ValidateClientNotice(m ClientNotice) error {
421 if !validClientNoticeAction(m.Action) {
422 return ErrInvalidClientNotice
423 }
424 return nil
425}
426
427func ValidateDetached(m Detached) error {
428 if !validDetachedReason(m.Reason) {
429 return ErrInvalidDetached
430 }
431 return nil
432}
433
434func ValidateHello(h Hello) error {
435 if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
436 return ErrInvalidHello
437 }
438 if err := ValidateGeometry(domain.Geometry{Size: h.Size, PixelWidth: h.PixelWidth, PixelHeight: h.PixelHeight}); err != nil {
439 return fmt.Errorf("%w: geometry", ErrInvalidHello)
440 }
441 if !validEnvironmentPolicy(h.EnvironmentPolicy) || !h.TerminalFocus.Valid() || !h.Color.Valid() || (h.Remote && h.ClientPID != 0) {
442 return ErrInvalidHello
443 }
444 if err := ValidateNavigation(h.Intent, h.NavigationCapabilities); err != nil {
445 return fmt.Errorf("%w: navigation: %w", ErrInvalidHello, err)
446 }
447 if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || uint64(len(h.Env)) > math.MaxUint32 {
448 return ErrInvalidHello
449 }
450 for _, entry := range h.Env {
451 if uint64(len(entry)) > math.MaxUint32 {
452 return ErrInvalidHello
453 }
454 }
455 if h.PreferredTabID != "" {
456 if h.Intent != IntentAttach && h.Intent != IntentResume {
457 return ErrInvalidHello
458 }
459 if err := domain.ValidateTabStableID(h.PreferredTabID); err != nil {
460 return fmt.Errorf("%w: preferred tab: %v", ErrInvalidHello, err)
461 }
462 }
463 if h.ExactTarget != nil {
464 if h.Intent != IntentAttach && h.Intent != IntentResume {
465 return ErrInvalidHello
466 }
467 if err := h.ExactTarget.Validate(); err != nil {
468 return fmt.Errorf("%w: exact target: %v", ErrInvalidHello, err)
469 }
470 if h.Name != h.ExactTarget.SessionName {
471 return ErrInvalidHello
472 }
473 if h.SessionTarget != nil && (h.SessionTarget.LifecycleID != h.ExactTarget.LifecycleID || h.SessionTarget.SessionName != h.ExactTarget.SessionName) {
474 return ErrInvalidHello
475 }
476 }
477 if h.SessionTarget == nil {
478 return nil
479 }
480 if h.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || (h.Intent != IntentAttach && h.Intent != IntentResume) {
481 return ErrInvalidHello
482 }
483 if err := h.SessionTarget.Validate(); err != nil {
484 return fmt.Errorf("%w: session target: %v", ErrInvalidHello, err)
485 }
486 if h.Name != h.SessionTarget.SessionName {
487 return ErrInvalidHello
488 }
489 return nil
490}
491
492func ValidateOutput(m Output) error {
493 if m.Epoch == 0 {
494 return ErrInvalidOutput
495 }
496 if m.New == 0 {
497 if m.Base != 0 || m.Full || m.Context != nil {
498 return ErrInvalidOutput
499 }
500 } else if m.Context == nil || (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
501 return ErrInvalidOutput
502 }
503 if err := ValidateSize(m.Size); err != nil {
504 return fmt.Errorf("%w: size", ErrInvalidOutput)
505 }
506 if m.Context != nil {
507 if err := m.Context.Validate(); err != nil {
508 return fmt.Errorf("%w: context: %w", ErrInvalidOutput, err)
509 }
510 }
511 if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxOutputDataLen {
512 return ErrInvalidOutput
513 }
514 return nil
515}
516
517func ValidateAck(m Ack) error {
518 if m.Epoch == 0 {
519 return ErrInvalidAck
520 }
521 return nil
522}
523
524func ValidateAttachTarget(m AttachTarget) error {
525 if m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
526 return ErrInvalidAttachTarget
527 }
528 if err := domain.ValidateSessionName(m.Session); err != nil {
529 return ErrInvalidAttachTarget
530 }
531 if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
532 return ErrInvalidAttachTarget
533 }
534 if m.Intent == IntentResume || !validEnvironmentPolicy(m.EnvironmentPolicy) {
535 return ErrInvalidAttachTarget
536 }
537 if m.Intent == IntentNew && m.Endpoint != "" && m.RequestID == 0 || m.RequestID != 0 && m.Intent != IntentNew {
538 return ErrInvalidAttachTarget
539 }
540 if m.ExactTarget != nil && (m.ExactTarget.SessionName != m.Session || m.ExactTarget.Validate() != nil) {
541 return ErrInvalidAttachTarget
542 }
543 if m.SamePeer && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil) {
544 return ErrInvalidAttachTarget
545 }
546 if m.PreferredTabID != "" && (m.Endpoint != "" || m.SessionTarget != nil || m.ExactTarget == nil || m.Intent != IntentAttach || domain.ValidateTabStableID(m.PreferredTabID) != nil) {
547 return ErrInvalidAttachTarget
548 }
549 if m.SessionTarget == nil {
550 return nil
551 }
552 if m.Endpoint != "" || m.EnvironmentPolicy != EnvironmentPolicyDaemonOwned || m.Intent != IntentAttach {
553 return ErrInvalidAttachTarget
554 }
555 if err := m.SessionTarget.Validate(); err != nil {
556 return fmt.Errorf("%w: session target: %v", ErrInvalidAttachTarget, err)
557 }
558 if m.Session != m.SessionTarget.SessionName {
559 return ErrInvalidAttachTarget
560 }
561 return nil
562}