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