返回 DeepSeek-Reasonix
client.go
根目录 / internal / extension / sidecar / client.go
1 package sidecar
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log/slog"
9 "slices"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "time"
14
15 "reasonix/internal/extension"
16 "reasonix/internal/extension/protocol"
17 "reasonix/internal/extension/rpcwire"
18 "reasonix/internal/pluginpkg"
19 "reasonix/internal/secrets"
20 )
21
22 // Lifecycle budgets.
23 const (
24 // defaultHandshakeTimeout bounds the extension/initialize round trip.
25 defaultHandshakeTimeout = 30 * time.Second
26 // defaultShutdownRequestTimeout bounds the extension/shutdown request
27 // before the bounded process close takes over.
28 defaultShutdownRequestTimeout = 5 * time.Second
29 // queuedNotifications bounds the ordered notification queue (provider
30 // stream chunks). A full queue fails the connection rather than dropping.
31 queuedNotifications = 256
32 // defaultWriteStallBound caps stalled outbound writes (sidecar not reading).
33 defaultWriteStallBound = 10 * time.Second
34 // maxInterceptTimeout is the 60s ceiling every sync-intercept budget is
35 // clamped to, including manifest overrides.
36 maxInterceptTimeout = 60 * time.Second
37 // fastInterceptTimeout is the default budget for the latency-sensitive
38 // input/tool/permission points.
39 fastInterceptTimeout = 5 * time.Second
40 // slowInterceptTimeout is the default budget for the session, context,
41 // system-prompt, and compaction family (and any point not on the fast
42 // path).
43 slowInterceptTimeout = 30 * time.Second
44 )
45
46 // UIHandler serves the extension's Extension → Host UI calls. Stage 8 wires
47 // real frontends; the nil default answers "ui not available".
48 type UIHandler interface {
49 Publish(ctx context.Context, p protocol.UIPublishParams) (protocol.UIPublishResult, error)
50 Request(ctx context.Context, p protocol.UIRequestParams) (protocol.UIRequestResult, error)
51 }
52
53 // UIBinder is the optional interface a UIHandler implements to receive
54 // per-plugin bindings and crash notifications from StartPackages (the stage-8
55 // UI hub). HandlerFor returns the handler installed on one client's
56 // connection; ClientCrashed reports that plugin's sidecar dying.
57 type UIBinder interface {
58 UIHandler
59 HandlerFor(pluginID string) UIHandler
60 ClientCrashed(pluginID string)
61 }
62
63 // unavailableUIHandler is the default UIHandler: every call fails with the
64 // frozen unknown_method reason so an extension depending on UI degrades
65 // loudly instead of blocking forever.
66 type unavailableUIHandler struct{}
67
68 func (unavailableUIHandler) Publish(context.Context, protocol.UIPublishParams) (protocol.UIPublishResult, error) {
69 return protocol.UIPublishResult{}, &protocol.ProtocolError{Reason: protocol.ErrUnknownMethod, Message: "extension UI is not available on this host"}
70 }
71
72 func (unavailableUIHandler) Request(context.Context, protocol.UIRequestParams) (protocol.UIRequestResult, error) {
73 return protocol.UIRequestResult{}, &protocol.ProtocolError{Reason: protocol.ErrUnknownMethod, Message: "extension UI is not available on this host"}
74 }
75
76 // StreamRouter receives the extension's provider stream notifications. The
77 // stage 7 adapter (internal/extension/providerext) installs the real router
78 // through SetStreamRouter; the nil default drops with a debug log.
79 type StreamRouter interface {
80 RouteStreamChunk(p protocol.StreamChunkParams)
81 RouteStreamEnd(p protocol.StreamEndParams)
82 }
83
84 type dropStreamRouter struct{ pluginID string }
85
86 func (r dropStreamRouter) RouteStreamChunk(p protocol.StreamChunkParams) {
87 slog.Debug("sidecar: dropping provider stream chunk (no stream router)", "plugin", r.pluginID, "stream", p.StreamID, "seq", p.Seq)
88 }
89
90 func (r dropStreamRouter) RouteStreamEnd(p protocol.StreamEndParams) {
91 slog.Debug("sidecar: dropping provider stream end (no stream router)", "plugin", r.pluginID, "stream", p.StreamID)
92 }
93
94 // ClientOptions configures one sidecar client.
95 type ClientOptions struct {
96 // Package and Installed are the pluginpkg installed-state entry this
97 // sidecar launches for. Package.Manifest.Runtime must be non-nil.
98 Package pluginpkg.Package
99 Installed pluginpkg.InstalledPlugin
100 // Session identifies the session the extension serves.
101 Session protocol.SessionContext
102 // UI routes host/ui/* calls; nil means "ui not available".
103 UI UIHandler
104 // Streams routes provider stream notifications; nil drops them.
105 Streams StreamRouter
106 // OnCrash fires exactly once when a started sidecar's connection ends
107 // unexpectedly. Optional.
108 OnCrash func(error)
109 // UIHostKind declares which host surface family renders extension UI.
110 // Empty means headless.
111 UIHostKind protocol.UIHostKind
112 // HandshakeTimeout bounds extension/initialize; zero uses 30s.
113 HandshakeTimeout time.Duration
114 // WriteStallBound bounds how long any outbound write may make no progress
115 // (the sidecar is alive but has stopped reading stdin) before the
116 // connection fails and the process is killed. Zero uses 10s. Without it a
117 // wedged reader would hang intercepts, provider/UI calls, and shutdown.
118 WriteStallBound time.Duration
119 }
120
121 func (o *ClientOptions) validate() error {
122 if o.Package.Manifest.Runtime == nil {
123 return fmt.Errorf("sidecar: plugin %q declares no runtime", o.Installed.Name)
124 }
125 if strings.TrimSpace(o.Installed.Name) == "" {
126 return errors.New("sidecar: installed plugin name is required")
127 }
128 if strings.TrimSpace(o.Session.SessionID) == "" || strings.TrimSpace(o.Session.WorkspaceRoot) == "" {
129 return errors.New("sidecar: session context requires a session ID and workspace root")
130 }
131 return nil
132 }
133
134 type handshakeState uint8
135
136 const (
137 handshakeNew handshakeState = iota
138 handshakeReady
139 handshakePoisoned
140 handshakeShutdown
141 )
142
143 // Client is one live sidecar connection: the rpcwire transport, the handshake
144 // state, the content store, and the process handle.
145 type Client struct {
146 pluginID string
147 version string
148 rt *pluginpkg.RuntimeSpec
149 requires []pluginpkg.CapabilityRef // manifest v2 dependency requirements
150 provides []pluginpkg.CapabilityRef // manifest v2 capability ceiling
151 session protocol.SessionContext
152 uiHost protocol.UIHostKind
153 handshakeTimeout time.Duration
154 proc *process
155 conn *rpcwire.Conn
156 store *Store
157 ui UIHandler
158 streams StreamRouter
159 streamsMu sync.RWMutex
160 onCrash func(error)
161 initResult protocol.InitializeResult
162
163 mu sync.Mutex
164 state handshakeState
165 poisoned error
166
167 crashed atomic.Bool
168 crashOnce sync.Once
169
170 shutdownOnce sync.Once
171 serveExited chan struct{}
172 seq atomic.Uint64
173 }
174
175 // StartClient spawns the sidecar and runs the initialize handshake. The host
176 // sends extension/initialize first; any Extension → Host traffic before the
177 // handshake completes poisons the connection and fails the start. On any
178 // failure the process is killed and reaped before StartClient returns.
179 func StartClient(ctx context.Context, opts ClientOptions) (*Client, error) {
180 started := time.Now()
181 if err := opts.validate(); err != nil {
182 return nil, err
183 }
184 p, err := startProcess(opts.Package, opts.Installed)
185 if err != nil {
186 return nil, err
187 }
188 c := newClient(p, opts)
189 go c.supervise()
190 if err := c.handshake(ctx); err != nil {
191 // The handshake owns the connection until ready: unwind it by killing
192 // the tree, then reap and drain the serve loop, all bounded.
193 c.proc.kill()
194 waitWithBudget(c.proc.wait, closeWaitBudget)
195 select {
196 case <-c.serveExited:
197 case <-time.After(closeWaitBudget):
198 }
199 return nil, newStartupFailure("handshake", started, p.stderr.String(), err)
200 }
201 return c, nil
202 }
203
204 func newClient(p *process, opts ClientOptions) *Client {
205 ui := opts.UI
206 if ui == nil {
207 ui = unavailableUIHandler{}
208 }
209 streams := opts.Streams
210 if streams == nil {
211 streams = dropStreamRouter{pluginID: p.pluginID}
212 }
213 uiHost := opts.UIHostKind
214 if uiHost == "" {
215 uiHost = protocol.UIHostHeadless
216 }
217 handshakeTimeout := opts.HandshakeTimeout
218 if handshakeTimeout <= 0 {
219 handshakeTimeout = defaultHandshakeTimeout
220 }
221 stallBound := opts.WriteStallBound
222 if stallBound <= 0 {
223 stallBound = defaultWriteStallBound
224 }
225 version := strings.TrimSpace(opts.Installed.Version)
226 if version == "" {
227 version = strings.TrimSpace(opts.Package.Manifest.Version)
228 }
229 c := &Client{
230 pluginID: p.pluginID,
231 version: version,
232 rt: opts.Package.Manifest.Runtime,
233 requires: append([]pluginpkg.CapabilityRef(nil), opts.Package.Manifest.Requires...),
234 provides: append([]pluginpkg.CapabilityRef(nil), opts.Package.Manifest.Provides...),
235 session: opts.Session,
236 uiHost: uiHost,
237 handshakeTimeout: handshakeTimeout,
238 proc: p,
239 store: NewStore(),
240 ui: ui,
241 streams: streams,
242 onCrash: opts.OnCrash,
243 serveExited: make(chan struct{}),
244 }
245 c.conn = rpcwire.NewConn(p.stdout, p.stdin, rpcwire.Options{
246 Name: "extension:" + p.pluginID,
247 MaxInboundBytes: protocol.FrameBytes,
248 MaxOutboundBytes: protocol.FrameBytes,
249 StrictJSONRPC: true,
250 MaxQueuedNotifications: queuedNotifications,
251 MaxWriteStall: stallBound,
252 BeforeRequest: c.beforeRequest,
253 BeforeNotification: c.beforeNotification,
254 })
255 c.conn.Handle(string(protocol.MethodHostContentRead), c.store.ReadHandler)
256 c.conn.Handle(string(protocol.MethodHostUIPublish), c.handleUIPublish)
257 c.conn.Handle(string(protocol.MethodHostUIRequest), c.handleUIRequest)
258 c.conn.HandleNotify(string(protocol.MethodExtensionProviderStreamChunk), c.handleStreamChunk)
259 c.conn.HandleNotify(string(protocol.MethodExtensionProviderStreamEnd), c.handleStreamEnd)
260 return c
261 }
262
263 // supervise runs the read loop for the life of the connection and turns an
264 // unexpected end into exactly one crash notification.
265 func (c *Client) supervise() {
266 err := c.conn.Serve(context.Background())
267 go c.proc.wait() // reap the zombie promptly; bounded callers never wait on it
268 c.mu.Lock()
269 orderly := c.state == handshakeShutdown
270 started := c.state == handshakeReady
271 c.mu.Unlock()
272 if !orderly {
273 crashErr := err
274 if crashErr == nil {
275 crashErr = errors.New("extension sidecar exited")
276 }
277 // An unexpected end can leave the process ALIVE but unreachable — a
278 // wedged reader whose pipe writes stalled out, for example. Kill the
279 // tree so it never outlives its connection; for a genuinely crashed
280 // sidecar the kill is a no-op.
281 go c.proc.kill()
282 c.crashed.Store(true)
283 if started && c.onCrash != nil {
284 c.crashOnce.Do(func() { c.onCrash(crashErr) })
285 }
286 }
287 close(c.serveExited)
288 }
289
290 // beforeRequest gates Extension → Host requests on handshake completion,
291 // running on the read loop so the decision observes wire arrival order. Any
292 // request before initialized poisons the connection: the sidecar broke the
293 // protocol's first-rule and cannot be trusted further.
294 func (c *Client) beforeRequest(method string, _ json.RawMessage) error {
295 c.mu.Lock()
296 defer c.mu.Unlock()
297 if c.state != handshakeReady {
298 c.poisonLocked(fmt.Errorf("extension %s sent request %q before extension/initialized", c.pluginID, method))
299 return protocol.MustProtocolError(protocol.ErrProtocolError).RPCError()
300 }
301 return nil
302 }
303
304 // beforeNotification applies the same gate to notifications: provider stream
305 // traffic is only valid once the handshake completed.
306 func (c *Client) beforeNotification(method string, _ json.RawMessage) error {
307 c.mu.Lock()
308 defer c.mu.Unlock()
309 if c.state != handshakeReady {
310 c.poisonLocked(fmt.Errorf("extension %s sent notification %q before extension/initialized", c.pluginID, method))
311 return protocol.MustProtocolError(protocol.ErrProtocolError).RPCError()
312 }
313 return nil
314 }
315
316 // poisonLocked records the protocol violation and kills the process so a
317 // pending handshake unwinds immediately instead of waiting out its timeout.
318 func (c *Client) poisonLocked(err error) {
319 if c.state == handshakePoisoned || c.state == handshakeShutdown {
320 return
321 }
322 c.state = handshakePoisoned
323 c.poisoned = err
324 go c.proc.kill()
325 }
326
327 // handshake sends extension/initialize (the host's first and only opening
328 // move), validates the sidecar's declarations against the manifest, and
329 // finishes with extension/initialized.
330 func (c *Client) handshake(ctx context.Context) error {
331 return c.handshakeWithTimeout(ctx, c.handshakeTimeout)
332 }
333
334 func (c *Client) handshakeWithTimeout(ctx context.Context, timeout time.Duration) error {
335 tctx, cancel := context.WithTimeout(ctx, timeout)
336 defer cancel()
337 params := c.initializeParams()
338 raw, err := c.conn.Request(tctx, string(protocol.MethodExtensionInitialize), params)
339 if err != nil {
340 if perr := c.poisonError(); perr != nil {
341 return perr
342 }
343 return mapRequestError(err)
344 }
345 decoded, err := protocol.DecodeHostRequestResult(protocol.MethodExtensionInitialize, raw)
346 if err != nil {
347 return &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "invalid initialize result: " + err.Error()}
348 }
349 result := decoded.(protocol.InitializeResult)
350 if err := c.validateHandshakeResult(result); err != nil {
351 return err
352 }
353 c.mu.Lock()
354 if c.state == handshakePoisoned {
355 poisoned := c.poisoned
356 c.mu.Unlock()
357 return &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: poisoned.Error()}
358 }
359 c.state = handshakeReady
360 c.initResult = result
361 c.mu.Unlock()
362 if err := c.conn.Notify(string(protocol.MethodExtensionInitialized), protocol.InitializedParams{}); err != nil {
363 return err
364 }
365 return nil
366 }
367
368 func (c *Client) poisonError() error {
369 c.mu.Lock()
370 defer c.mu.Unlock()
371 if c.state == handshakePoisoned && c.poisoned != nil {
372 return &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: c.poisoned.Error()}
373 }
374 return nil
375 }
376
377 // validateHandshakeResult enforces the declaration contract: the sidecar's
378 // protocol version must be supported, and every capability it activated must
379 // be a subset of what its installed manifest declared.
380 func (c *Client) validateHandshakeResult(result protocol.InitializeResult) error {
381 if err := protocol.CompareProtocolVersion(protocol.ProtocolID, result.ProtocolVersion); err != nil {
382 return err
383 }
384 rt := c.rt
385 capabilityErr := func(format string, args ...any) error {
386 return &protocol.ProtocolError{Reason: protocol.ErrCapabilityNotDeclared, Message: fmt.Sprintf(format, args...)}
387 }
388 for _, point := range result.Subscriptions {
389 if !containsString(rt.Intercepts, point) {
390 return capabilityErr("extension %s subscribed to %q which its manifest does not intercept", c.pluginID, point)
391 }
392 }
393 for _, slot := range result.Replaces {
394 if !containsString(rt.Replaces, slot) {
395 return capabilityErr("extension %s replaced %q which its manifest does not declare", c.pluginID, slot)
396 }
397 }
398 if len(result.Providers) > 0 {
399 if !containsString(rt.Capabilities, "providers") {
400 return capabilityErr("extension %s declared providers without the providers capability", c.pluginID)
401 }
402 prefix := "plugin/" + c.pluginID + "/"
403 for _, desc := range result.Providers {
404 if !strings.HasPrefix(desc.Ref, prefix) {
405 return capabilityErr("extension %s declared provider ref %q outside its %q namespace", c.pluginID, desc.Ref, prefix)
406 }
407 }
408 }
409 if len(result.UIActions) > 0 && !containsString(rt.Capabilities, "ui") {
410 return capabilityErr("extension %s declared UI actions without the ui capability", c.pluginID)
411 }
412 // Manifest provides is the capability ceiling: handshake must not claim
413 // capabilities the package never declared. Declared-but-missing provides
414 // stay Unavailable (no forge) — callers read Status via the lifecycle registry.
415 if err := validateProvidesCeiling(c.provides, result.Provides); err != nil {
416 return &protocol.ProtocolError{Reason: protocol.ErrCapabilityNotDeclared, Message: err.Error()}
417 }
418 return nil
419 }
420
421 // readyErr reports whether the client can serve calls right now.
422 func (c *Client) readyErr() error {
423 if c.crashed.Load() {
424 return &protocol.ProtocolError{Reason: protocol.ErrProviderInterrupted, Message: "extension sidecar " + c.pluginID + " crashed"}
425 }
426 c.mu.Lock()
427 state := c.state
428 c.mu.Unlock()
429 switch state {
430 case handshakeReady:
431 return nil
432 case handshakeShutdown:
433 return &protocol.ProtocolError{Reason: protocol.ErrProviderInterrupted, Message: "extension sidecar " + c.pluginID + " is shut down"}
434 default:
435 return &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "extension sidecar " + c.pluginID + " is not initialized"}
436 }
437 }
438
439 // PluginID returns the installed plugin package name this client serves.
440 func (c *Client) PluginID() string { return c.pluginID }
441
442 // Required reports whether the plugin's manifest marked its runtime
443 // required:true — the dispatcher treats such extensions as required-class.
444 func (c *Client) Required() bool { return c.rt.Required }
445
446 // Handshake returns the sidecar's validated initialize result — its declared
447 // subscriptions, replacements, providers, and UI actions.
448 func (c *Client) Handshake() protocol.InitializeResult {
449 c.mu.Lock()
450 defer c.mu.Unlock()
451 return c.initResult
452 }
453
454 // Store returns the client's content store for externalizing payloads.
455 func (c *Client) Store() *Store { return c.store }
456
457 // Crashed reports whether the connection ended unexpectedly.
458 func (c *Client) Crashed() bool { return c.crashed.Load() }
459
460 // Disconnected returns a channel closed when the connection's serve loop ends
461 // for any reason — crash, orderly shutdown, or transport failure. Provider
462 // stream watchers select on it to finish in-flight streams instead of hanging
463 // on notifications that will never arrive.
464 func (c *Client) Disconnected() <-chan struct{} { return c.serveExited }
465
466 // SetStreamRouter swaps the provider stream router (stage 7). Nil restores
467 // the drop-with-debug-log default. It is safe to call while notifications are
468 // in flight; routing for later notifications uses the new router.
469 func (c *Client) SetStreamRouter(r StreamRouter) {
470 if r == nil {
471 r = dropStreamRouter{pluginID: c.pluginID}
472 }
473 c.streamsMu.Lock()
474 c.streams = r
475 c.streamsMu.Unlock()
476 }
477
478 // streamRouter returns the currently installed router.
479 func (c *Client) streamRouter() StreamRouter {
480 c.streamsMu.RLock()
481 defer c.streamsMu.RUnlock()
482 return c.streams
483 }
484
485 // Exited reports whether the sidecar process has been reaped.
486 func (c *Client) Exited() bool {
487 select {
488 case <-c.proc.waitDone:
489 return true
490 default:
491 return false
492 }
493 }
494
495 // TimeoutFor resolves the sync-intercept budget for one point: the manifest's
496 // per-runtime override clamped to the 60s ceiling, or the point-family
497 // default (5s for input/tool/permission, 30s for the session, system-prompt,
498 // context, and compaction family).
499 func (c *Client) TimeoutFor(point extension.InterceptorPoint) time.Duration {
500 if c.rt.TimeoutMillis > 0 {
501 timeout := min(time.Duration(c.rt.TimeoutMillis)*time.Millisecond, maxInterceptTimeout)
502 return timeout
503 }
504 switch point {
505 case extension.PointInputReceive, extension.PointToolBefore,
506 extension.PointToolAfter, extension.PointPermissionDecision:
507 return fastInterceptTimeout
508 default:
509 return slowInterceptTimeout
510 }
511 }
512
513 // Intercept makes the blocking extension/intercept call. A late answer maps
514 // to the frozen intercept_timeout error; a crashed or closed sidecar fails
515 // fast with the provider_interrupted family instead of waiting. A payload
516 // above protocol.ExternalizeFieldBytes moves into this connection's content
517 // store and travels as a content-ref envelope; an externalized replacement in
518 // the answer is paged back and verified before the caller's strict decode.
519 func (c *Client) Intercept(ctx context.Context, event protocol.InterceptEvent, payload json.RawMessage, timeout time.Duration) (protocol.InterceptResult, error) {
520 if err := c.readyErr(); err != nil {
521 return protocol.InterceptResult{}, err
522 }
523 if timeout <= 0 {
524 timeout = c.TimeoutFor(extension.InterceptorPoint(event))
525 }
526 tctx, cancel := context.WithTimeout(ctx, timeout)
527 defer cancel()
528 params := protocol.InterceptParams{
529 Event: event,
530 Seq: c.seq.Add(1),
531 Payload: payload,
532 TimeoutMillis: int(timeout.Milliseconds()),
533 }
534 if err := c.externalizeInterceptParams(&params); err != nil {
535 return protocol.InterceptResult{}, err
536 }
537 raw, err := c.conn.Request(tctx, string(protocol.MethodExtensionIntercept), params)
538 if err != nil {
539 if errors.Is(tctx.Err(), context.DeadlineExceeded) && ctx.Err() == nil {
540 return protocol.InterceptResult{}, &protocol.ProtocolError{
541 Reason: protocol.ErrInterceptTimeout,
542 Message: fmt.Sprintf("extension %s did not answer %s within %s", c.pluginID, event, timeout),
543 }
544 }
545 return protocol.InterceptResult{}, mapRequestError(err)
546 }
547 decoded, err := protocol.DecodeHostRequestResult(protocol.MethodExtensionIntercept, raw)
548 if err != nil {
549 return protocol.InterceptResult{}, &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "invalid intercept result: " + err.Error()}
550 }
551 result := decoded.(protocol.InterceptResult)
552 if err := c.resolveExternalizedReplacement(&result); err != nil {
553 return protocol.InterceptResult{}, err
554 }
555 return result, nil
556 }
557
558 // TryNotifyEvent non-blockingly enqueues the fire-and-forget extension/event
559 // observation. The payload follows the same content-ref rule as
560 // extension/intercept. Queue saturation drops the observation instead of
561 // propagating sidecar backpressure into the Agent hot path.
562 func (c *Client) TryNotifyEvent(event protocol.InterceptEvent, payload json.RawMessage) error {
563 if err := c.readyErr(); err != nil {
564 return err
565 }
566 params := protocol.EventParams{Event: event, Payload: payload}
567 if err := c.externalizeEventParams(&params); err != nil {
568 return err
569 }
570 return c.conn.TryNotify(string(protocol.MethodExtensionEvent), params)
571 }
572
573 // NotifyEvent is the compatibility spelling for direct callers. Its delivery
574 // semantics are the same non-blocking enqueue as TryNotifyEvent.
575 func (c *Client) NotifyEvent(event protocol.InterceptEvent, payload json.RawMessage) error {
576 return c.TryNotifyEvent(event, payload)
577 }
578
579 // NotifyResourcesChanged sends extension/resources/changed.
580 func (c *Client) NotifyResourcesChanged(paths []string) error {
581 if err := c.readyErr(); err != nil {
582 return err
583 }
584 return c.conn.Notify(string(protocol.MethodExtensionResourcesChanged), protocol.ResourcesChangedParams{Paths: paths})
585 }
586
587 // UIAction invokes one handshake-declared UI action on the sidecar (stage 8).
588 // The host UI hub routes /<plugin>:<action> invocations here. A crashed or
589 // shut-down sidecar fails fast with the provider_interrupted reason.
590 func (c *Client) UIAction(ctx context.Context, params protocol.UIActionParams) (protocol.UIActionResult, error) {
591 if err := c.readyErr(); err != nil {
592 return protocol.UIActionResult{}, err
593 }
594 raw, err := c.conn.Request(ctx, string(protocol.MethodExtensionUIAction), params)
595 if err != nil {
596 return protocol.UIActionResult{}, mapRequestError(err)
597 }
598 decoded, err := protocol.DecodeHostRequestResult(protocol.MethodExtensionUIAction, raw)
599 if err != nil {
600 return protocol.UIActionResult{}, &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "invalid UI action result: " + err.Error()}
601 }
602 return decoded.(protocol.UIActionResult), nil
603 }
604
605 // UISubmit delivers a form surface's values back to the sidecar (stage 8).
606 // The host UI hub routes submissions here.
607 func (c *Client) UISubmit(ctx context.Context, params protocol.UISubmitParams) (protocol.UISubmitResult, error) {
608 if err := c.readyErr(); err != nil {
609 return protocol.UISubmitResult{}, err
610 }
611 raw, err := c.conn.Request(ctx, string(protocol.MethodExtensionUISubmit), params)
612 if err != nil {
613 return protocol.UISubmitResult{}, mapRequestError(err)
614 }
615 decoded, err := protocol.DecodeHostRequestResult(protocol.MethodExtensionUISubmit, raw)
616 if err != nil {
617 return protocol.UISubmitResult{}, &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "invalid UI submit result: " + err.Error()}
618 }
619 return decoded.(protocol.UISubmitResult), nil
620 }
621
622 // ProviderCatalog fetches the sidecar's extension-hosted provider catalog
623 // (stage 7). The result carries no credentials — the sidecar's refs,
624 // descriptors, and declared capabilities only.
625 func (c *Client) ProviderCatalog(ctx context.Context) ([]protocol.ProviderDescriptor, error) {
626 if err := c.readyErr(); err != nil {
627 return nil, err
628 }
629 raw, err := c.conn.Request(ctx, string(protocol.MethodExtensionProviderCatalog), protocol.ProviderCatalogParams{})
630 if err != nil {
631 return nil, mapRequestError(err)
632 }
633 decoded, err := protocol.DecodeHostRequestResult(protocol.MethodExtensionProviderCatalog, raw)
634 if err != nil {
635 return nil, &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "invalid provider catalog result: " + err.Error()}
636 }
637 return decoded.(protocol.ProviderCatalogResult).Providers, nil
638 }
639
640 // ProviderStreamOpen asks the sidecar to start one provider stream (stage 7).
641 // Accepted streams deliver chunks as extension/provider/stream/chunk
642 // notifications routed to the installed StreamRouter and exactly one
643 // stream/end. A crashed or shut-down sidecar fails fast with the
644 // provider_interrupted reason.
645 func (c *Client) ProviderStreamOpen(ctx context.Context, params protocol.StreamOpenParams) (protocol.StreamOpenResult, error) {
646 if err := c.readyErr(); err != nil {
647 return protocol.StreamOpenResult{}, err
648 }
649 raw, err := c.conn.Request(ctx, string(protocol.MethodExtensionProviderStreamOpen), params)
650 if err != nil {
651 return protocol.StreamOpenResult{}, mapRequestError(err)
652 }
653 decoded, err := protocol.DecodeHostRequestResult(protocol.MethodExtensionProviderStreamOpen, raw)
654 if err != nil {
655 return protocol.StreamOpenResult{}, &protocol.ProtocolError{Reason: protocol.ErrProtocolError, Message: "invalid stream open result: " + err.Error()}
656 }
657 return decoded.(protocol.StreamOpenResult), nil
658 }
659
660 // ProviderStreamCancel cancels one in-flight provider stream, best effort: a
661 // wedged or dead sidecar simply never answers inside the bounded budget.
662 func (c *Client) ProviderStreamCancel(streamID string) {
663 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
664 defer cancel()
665 _, _ = c.conn.Request(ctx, string(protocol.MethodExtensionProviderStreamCancel), protocol.StreamCancelParams{StreamID: streamID})
666 }
667
668 // Shutdown stops the sidecar with the bounded sequence: extension/shutdown
669 // (bounded by timeout), close stdin, a 750ms EOF grace, a process-tree kill,
670 // and a 5s reap. It is idempotent; later calls return immediately.
671 func (c *Client) Shutdown(_ context.Context, timeout time.Duration) error {
672 c.shutdownOnce.Do(func() {
673 c.mu.Lock()
674 wasReady := c.state == handshakeReady
675 if c.state != handshakePoisoned {
676 c.state = handshakeShutdown
677 }
678 c.mu.Unlock()
679 if timeout <= 0 {
680 timeout = defaultShutdownRequestTimeout
681 }
682 if wasReady && !c.crashed.Load() && c.conn != nil {
683 tctx, cancel := context.WithTimeout(context.Background(), timeout)
684 _, _ = c.conn.Request(tctx, string(protocol.MethodExtensionShutdown), protocol.ShutdownParams{
685 TimeoutMillis: int(timeout.Milliseconds()),
686 })
687 cancel()
688 }
689 if c.proc != nil {
690 c.proc.close()
691 }
692 if c.serveExited != nil {
693 select {
694 case <-c.serveExited:
695 case <-time.After(closeWaitBudget):
696 }
697 }
698 })
699 return nil
700 }
701
702 // Close shuts the sidecar down with default budgets.
703 func (c *Client) Close() error {
704 return c.Shutdown(context.Background(), defaultShutdownRequestTimeout)
705 }
706
707 func (c *Client) handleUIPublish(ctx context.Context, raw json.RawMessage) (any, error) {
708 decoded, err := protocol.DecodeExtensionRequestParams(protocol.MethodHostUIPublish, raw)
709 if err != nil {
710 return nil, protocol.MustProtocolError(protocol.ErrInvalidParams).RPCError()
711 }
712 result, err := c.ui.Publish(ctx, decoded.(protocol.UIPublishParams))
713 if err != nil {
714 return nil, mapHandlerError(err)
715 }
716 return result, nil
717 }
718
719 func (c *Client) handleUIRequest(ctx context.Context, raw json.RawMessage) (any, error) {
720 decoded, err := protocol.DecodeExtensionRequestParams(protocol.MethodHostUIRequest, raw)
721 if err != nil {
722 return nil, protocol.MustProtocolError(protocol.ErrInvalidParams).RPCError()
723 }
724 result, err := c.ui.Request(ctx, decoded.(protocol.UIRequestParams))
725 if err != nil {
726 return nil, mapHandlerError(err)
727 }
728 return result, nil
729 }
730
731 func (c *Client) handleStreamChunk(_ context.Context, raw json.RawMessage) {
732 decoded, err := protocol.DecodeExtensionNotificationParams(protocol.MethodExtensionProviderStreamChunk, raw)
733 if err != nil {
734 slog.Debug("sidecar: dropping malformed stream chunk", "plugin", c.pluginID, "err", err)
735 return
736 }
737 c.streamRouter().RouteStreamChunk(decoded.(protocol.StreamChunkParams))
738 }
739
740 func (c *Client) handleStreamEnd(_ context.Context, raw json.RawMessage) {
741 decoded, err := protocol.DecodeExtensionNotificationParams(protocol.MethodExtensionProviderStreamEnd, raw)
742 if err != nil {
743 slog.Debug("sidecar: dropping malformed stream end", "plugin", c.pluginID, "err", err)
744 return
745 }
746 c.streamRouter().RouteStreamEnd(decoded.(protocol.StreamEndParams))
747 }
748
749 // mapHandlerError converts a UIHandler failure into a wire-safe error.
750 func mapHandlerError(err error) error {
751 var protocolErr *protocol.ProtocolError
752 if errors.As(err, &protocolErr) {
753 return protocolErr.RPCError()
754 }
755 var rpcErr *rpcwire.RPCError
756 if errors.As(err, &rpcErr) {
757 return rpcErr
758 }
759 return protocol.MustProtocolError(protocol.ErrInternal).RPCError()
760 }
761
762 // mapRequestError converts a failed outbound call: peer protocol errors keep
763 // their frozen reason; transport endings map to the crash/shutdown family.
764 func mapRequestError(err error) error {
765 var respErr *rpcwire.ResponseError
766 if errors.As(err, &respErr) {
767 message := secrets.RedactCredentials(respErr.Message)
768 var data protocol.ProtocolErrorData
769 if len(respErr.Data) > 0 && json.Unmarshal(respErr.Data, &data) == nil && data.Validate() == nil {
770 return &protocol.ProtocolError{Reason: data.Reason, Message: message}
771 }
772 // Invalid or absent protocol data still came from the untrusted peer.
773 // Preserve the transport code for diagnostics, but never let its message
774 // bypass the host's credential-redaction boundary.
775 return &rpcwire.ResponseError{Code: respErr.Code, Message: message, Data: append(json.RawMessage(nil), respErr.Data...)}
776 }
777 return err
778 }
779
780 func containsString(items []string, value string) bool {
781 return slices.Contains(items, value)
782 }
783
783 lines GO