返回 DeepSeek-Reasonix
sdk.go
根目录 / sdk / go / sdk.go
1 // Package extension is the Go SDK for Reasonix extension sidecars speaking
2 // Extension Protocol v1 over stdio. An extension is a separate process: the
3 // Reasonix host launches it, sends extension/initialize first, drives
4 // intercepts, events, provider streams, and UI calls, and finally asks it to
5 // stop with extension/shutdown.
6 //
7 // The transport is strict JSON-RPC 2.0 framed as NDJSON (one object per
8 // line, integer request ids, params as JSON objects, frames capped at
9 // FrameBytes). The SDK owns the wire, the handshake barrier, and the
10 // shutdown sequence; the extension implements Handler and, optionally,
11 // interceptors, a Provider, and UI callbacks via Options. After Initialize
12 // completes, the SDK may invoke up to 32 callbacks concurrently; extensions
13 // must synchronize any mutable state shared by those callbacks.
14 //
15 // After Serve returns nil from an orderly extension/shutdown the process
16 // should exit with code 0; the host reaps it by that exit status.
17 //
18 // Protocol reference: docs/EXTENSION_PROTOCOL.generated.md and
19 // internal/extension/protocol/schema.generated.json in the Reasonix
20 // repository.
21 package extension
22
23 import (
24 "bytes"
25 "context"
26 "crypto/sha256"
27 "encoding/base64"
28 "encoding/hex"
29 "encoding/json"
30 "errors"
31 "fmt"
32 "io"
33 "log"
34 "os"
35 "strconv"
36 "strings"
37 "sync"
38 "time"
39 )
40
41 // ---------------------------------------------------------------------------
42 // Public callback types
43 // ---------------------------------------------------------------------------
44
45 // Handler is the one mandatory extension hook. Initialize is called once and
46 // completes before any other callback. Return the sidecar's declaration
47 // (name, version, subscriptions, replaces, providers, UI actions) — the host
48 // rejects anything beyond the installed manifest.
49 type Handler interface {
50 Initialize(ctx context.Context, p InitializeParams) (*InitializeResult, error)
51 }
52
53 // InterceptorFunc rules on one intercepted event. payload is the event
54 // payload as raw JSON; content-ref externalized payloads are rehydrated
55 // before the call. Return one of Continue, Block, Replace, Allow, or Deny; a
56 // nil result is Continue. A non-nil error answers the intercept with the
57 // frozen internal error and the host proceeds with its default behavior.
58 type InterceptorFunc func(ctx context.Context, event string, payload json.RawMessage) (*InterceptResult, error)
59
60 // Provider brokers extension-hosted model providers. The extension holds the
61 // credentials; only the credential-free DTOs cross the wire.
62 type Provider interface {
63 // Catalog returns the extension's full provider catalog. It may run
64 // concurrently with other callbacks.
65 Catalog(ctx context.Context) ([]ProviderDescriptor, error)
66 // Stream opens one stream and returns its chunk channel. Stream must
67 // return promptly; produce chunks in the background. The SDK numbers
68 // chunks 1,2,3,… (from the host's SeqBase) and ends the stream with
69 // exactly one stream/end: close the channel for a clean end, send an
70 // ErrorChunk (or a chunk with Type ChunkError) to fail the stream with
71 // end.error, and stop producing when ctx is cancelled (host cancel or
72 // shutdown) — the SDK then ends the stream interrupted. Multiple Stream
73 // calls may run concurrently.
74 Stream(ctx context.Context, req StreamRequest) (<-chan StreamChunk, error)
75 }
76
77 // StreamRequest is one opened provider stream.
78 type StreamRequest struct {
79 StreamID string
80 ProviderRef string
81 Model string
82 Effort string
83 Request ProviderRequest
84 }
85
86 // StreamChunk is one chunk a Provider produces; it is exactly the wire
87 // ProviderChunk. Build them with TextChunk, ReasoningChunk, UsageChunk,
88 // DoneChunk, and ErrorChunk.
89 type StreamChunk = ProviderChunk
90
91 // TextChunk is one assistant text delta.
92 func TextChunk(text string) StreamChunk { return StreamChunk{Type: ChunkText, Text: text} }
93
94 // ReasoningChunk is one reasoning delta with its optional signature.
95 func ReasoningChunk(text, signature string) StreamChunk {
96 return StreamChunk{Type: ChunkReasoning, Text: text, Signature: signature}
97 }
98
99 // UsageChunk carries final token accounting.
100 func UsageChunk(usage ProviderUsage) StreamChunk {
101 return StreamChunk{Type: ChunkUsage, Usage: &usage}
102 }
103
104 // DoneChunk marks the logical end of the assistant turn. The stream itself
105 // ends when the channel closes.
106 func DoneChunk() StreamChunk { return StreamChunk{Type: ChunkDone} }
107
108 // ErrorChunk fails the stream. The SDK ends it with stream/end.error set to
109 // the chunk's message instead of forwarding the chunk. Keep the message
110 // generic: it crosses the wire and must never contain credentials, endpoints,
111 // or response bodies.
112 func ErrorChunk(message string) StreamChunk {
113 if strings.TrimSpace(message) == "" {
114 message = frozenErrorSpecs[ErrProviderFailed].Message
115 }
116 return StreamChunk{Type: ChunkError, Error: &ProviderError{Code: ProviderFailed, Message: message}}
117 }
118
119 // UIHandler carries the extension's UI callbacks. A nil func makes the
120 // matching method answer unknown_method.
121 type UIHandler struct {
122 // Action runs one handshake-declared action. A non-nil error answers
123 // with {accepted:false, message}.
124 Action func(ctx context.Context, actionID string, args map[string]string) error
125 // Submit consumes one published form surface's values. A non-nil error
126 // answers with {accepted:false} and is logged.
127 Submit func(ctx context.Context, surfaceID string, values map[string]any) error
128 }
129
130 // Options configures Serve. Stdin/Stdout default to os.Stdin/os.Stdout. After
131 // Initialize, callback fields and Provider methods may be invoked concurrently
132 // (up to 32 inbound handlers); protect shared mutable maps, slices, counters,
133 // and clients with synchronization appropriate to the extension.
134 type Options struct {
135 Stdin io.Reader
136 Stdout io.Writer
137 // Name and Version fill InitializeResult when the Handler leaves them
138 // empty.
139 Name string
140 Version string
141 // Interceptors maps an event name ("session.start", …) to its ruling
142 // func; "*" is the wildcard fallback for events without an exact entry.
143 Interceptors map[string]InterceptorFunc
144 // Observer receives extension/event notifications. Events are
145 // fire-and-forget; the observer cannot change host behavior.
146 Observer func(ctx context.Context, event string, payload json.RawMessage)
147 // ResourcesChanged receives extension/resources/changed notifications.
148 ResourcesChanged func(ctx context.Context, paths []string)
149 // Provider serves extension/provider/*; nil answers those methods with
150 // unknown_method.
151 Provider Provider
152 // UI serves extension/ui/action and extension/ui/submit.
153 UI UIHandler
154 // Shutdown runs on extension/shutdown, bounded by the host's
155 // TimeoutMillis. After it returns (or times out) the SDK answers
156 // {accepted:true} and closes the transport; the process should then
157 // exit(0).
158 Shutdown func(ctx context.Context)
159 // Logger receives stderr diagnostics (protocol violations, dropped
160 // notifications, handler errors). Defaults to a stderr logger.
161 Logger *log.Logger
162 }
163
164 // ---------------------------------------------------------------------------
165 // Sentinel errors
166 // ---------------------------------------------------------------------------
167
168 // ErrNotReady reports an Extension → Host call made before the handshake
169 // barrier opened: the sidecar must not send requests or notifications before
170 // the host's extension/initialized notification.
171 var ErrNotReady = errors.New("extension: host connection is not initialized (wait for extension/initialized)")
172
173 // ErrNoConnection reports a helper call (HostUI methods, ReadContentRef,
174 // ResolveExternalized) with a context that did not come from an SDK
175 // callback.
176 var ErrNoConnection = errors.New("extension: no host connection in context (use the context passed to an SDK callback)")
177
178 // ErrUICancelled reports a host prompt the user dismissed. UIRequestResult
179 // distinguishes dismissal from an empty value set; the SDK surfaces it as
180 // this sentinel.
181 var ErrUICancelled = errors.New("extension: the user dismissed the prompt")
182
183 // ---------------------------------------------------------------------------
184 // InterceptResult helpers
185 // ---------------------------------------------------------------------------
186
187 // Continue lets the event proceed unchanged.
188 func Continue() *InterceptResult { return &InterceptResult{Decision: DecisionContinue} }
189
190 // Block stops the event with a human-readable reason.
191 func Block(reason string) *InterceptResult {
192 return &InterceptResult{Decision: DecisionBlock, Reason: reason}
193 }
194
195 // Replace substitutes the event payload. payload may be a json.RawMessage
196 // (used verbatim, must be valid JSON) or any marshalable value. The
197 // replacement travels inline; only the host can mint content refs, so a
198 // replacement must fit in one frame.
199 func Replace(payload any) (*InterceptResult, error) {
200 var raw json.RawMessage
201 switch value := payload.(type) {
202 case json.RawMessage:
203 raw = value
204 case []byte:
205 raw = value
206 default:
207 encoded, err := json.Marshal(payload)
208 if err != nil {
209 return nil, fmt.Errorf("extension: marshal replacement: %w", err)
210 }
211 raw = encoded
212 }
213 if !json.Valid(raw) {
214 return nil, errors.New("extension: replacement is not valid JSON")
215 }
216 return &InterceptResult{Decision: DecisionReplace, Replacement: raw}, nil
217 }
218
219 // Allow grants a permission.decision intercept.
220 func Allow() *InterceptResult { return &InterceptResult{Decision: DecisionAllow} }
221
222 // Deny refuses a permission.decision intercept with a reason.
223 func Deny(reason string) *InterceptResult {
224 return &InterceptResult{Decision: DecisionDeny, Reason: reason}
225 }
226
227 // ---------------------------------------------------------------------------
228 // Serve
229 // ---------------------------------------------------------------------------
230
231 type serverState uint8
232
233 const (
234 stateNew serverState = iota
235 // stateHandshake is entered when extension/initialize arrives and held
236 // until the host's extension/initialized notification opens the barrier.
237 stateHandshake
238 stateReady
239 stateShutdown
240 )
241
242 type server struct {
243 conn *conn
244 handler Handler
245 opts Options
246 log *log.Logger
247
248 mu sync.Mutex
249 state serverState
250 shutdownOnce sync.Once
251
252 streamsMu sync.Mutex
253 streams map[string]*streamHandle
254 }
255
256 type streamHandle struct {
257 cancel context.CancelFunc
258 done chan struct{}
259 }
260
261 type serverContextKey struct{}
262
263 func serverFrom(ctx context.Context) *server {
264 s, _ := ctx.Value(serverContextKey{}).(*server)
265 return s
266 }
267
268 // Serve runs the extension sidecar lifecycle on Options.Stdin/Stdout until
269 // the host closes the transport, asks for shutdown, or fatally violates the
270 // protocol. It returns nil on a clean end (host EOF or an answered
271 // extension/shutdown) and a non-nil error otherwise; canceling ctx tears
272 // everything down and returns the ctx error. After an orderly shutdown the
273 // process should exit(0).
274 func Serve(ctx context.Context, h Handler, opts Options) error {
275 if h == nil {
276 return errors.New("extension: Serve requires a non-nil Handler")
277 }
278 stdin := opts.Stdin
279 if stdin == nil {
280 stdin = os.Stdin
281 }
282 stdout := opts.Stdout
283 if stdout == nil {
284 stdout = os.Stdout
285 }
286 logger := opts.Logger
287 if logger == nil {
288 logger = log.New(os.Stderr, "reasonix-extension: ", log.LstdFlags)
289 }
290 s := &server{
291 handler: h,
292 opts: opts,
293 log: logger,
294 state: stateNew,
295 streams: make(map[string]*streamHandle),
296 }
297 c := newConn(stdin, stdout, logger)
298 s.conn = c
299 c.beforeRequest = s.gateRequest
300 c.beforeNotification = s.gateNotification
301
302 c.reqH[MethodExtensionInitialize] = s.withConnRequest(s.handleInitialize)
303 c.reqH[MethodExtensionShutdown] = s.withConnRequest(s.handleShutdown)
304 c.reqH[MethodExtensionIntercept] = s.withConnRequest(s.handleIntercept)
305 c.reqH[MethodExtensionProviderCatalog] = s.withConnRequest(s.handleProviderCatalog)
306 c.reqH[MethodExtensionProviderStreamOpen] = s.withConnRequest(s.handleStreamOpen)
307 c.reqH[MethodExtensionProviderStreamCancel] = s.withConnRequest(s.handleStreamCancel)
308 c.reqH[MethodExtensionUIAction] = s.withConnRequest(s.handleUIAction)
309 c.reqH[MethodExtensionUISubmit] = s.withConnRequest(s.handleUISubmit)
310 c.notH[MethodExtensionInitialized] = s.withConnNotification(s.handleInitialized)
311 c.notH[MethodExtensionEvent] = s.withConnNotification(s.handleEvent)
312 c.notH[MethodExtensionResourcesChanged] = s.withConnNotification(s.handleResourcesChanged)
313
314 return c.serve(ctx)
315 }
316
317 // withConnRequest injects the server into handler contexts so HostUI,
318 // ReadContentRef, and ResolveExternalized can reach the transport.
319 func (s *server) withConnRequest(f requestHandler) requestHandler {
320 return func(ctx context.Context, raw json.RawMessage) (any, error) {
321 return f(context.WithValue(ctx, serverContextKey{}, s), raw)
322 }
323 }
324
325 func (s *server) withConnNotification(f notificationHandler) notificationHandler {
326 return func(ctx context.Context, raw json.RawMessage) {
327 f(context.WithValue(ctx, serverContextKey{}, s), raw)
328 }
329 }
330
331 // ---------------------------------------------------------------------------
332 // Handshake barrier
333 // ---------------------------------------------------------------------------
334
335 // gateRequest runs on the read loop before dispatch: the host must open with
336 // extension/initialize, and until its extension/initialized notification
337 // arrives only the lifecycle methods are served. Everything else is answered
338 // with the frozen protocol_error.
339 func (s *server) gateRequest(method string) error {
340 s.mu.Lock()
341 defer s.mu.Unlock()
342 switch s.state {
343 case stateReady:
344 return nil
345 case stateNew:
346 switch method {
347 case MethodExtensionInitialize:
348 s.state = stateHandshake
349 return nil
350 case MethodExtensionShutdown:
351 return nil
352 }
353 case stateHandshake:
354 if method == MethodExtensionShutdown {
355 return nil
356 }
357 case stateShutdown:
358 // fall through to the error below
359 }
360 return &ProtocolError{
361 Reason: ErrProtocolError,
362 Message: fmt.Sprintf("extension protocol violation: host sent request %q before the handshake completed", method),
363 }
364 }
365
366 // gateNotification applies the same barrier to notifications; violations are
367 // dropped (JSON-RPC notifications carry no response).
368 func (s *server) gateNotification(method string) error {
369 s.mu.Lock()
370 defer s.mu.Unlock()
371 switch s.state {
372 case stateReady:
373 return nil
374 case stateHandshake:
375 if method == MethodExtensionInitialized {
376 s.state = stateReady
377 return nil
378 }
379 }
380 return fmt.Errorf("extension: dropping notification %q before the handshake completed", method)
381 }
382
383 // checkReady gates Extension → Host calls on the opened barrier.
384 func (s *server) checkReady() error {
385 s.mu.Lock()
386 defer s.mu.Unlock()
387 if s.state != stateReady {
388 return ErrNotReady
389 }
390 return nil
391 }
392
393 // ---------------------------------------------------------------------------
394 // Lifecycle handlers
395 // ---------------------------------------------------------------------------
396
397 // fatalError marks handler failures that must end the connection after the
398 // error response is written (a failed handshake leaves nothing to serve).
399 type fatalError struct{ err error }
400
401 func (e *fatalError) Error() string { return e.err.Error() }
402 func (e *fatalError) Unwrap() error { return e.err }
403
404 func (s *server) handleInitialize(ctx context.Context, raw json.RawMessage) (any, error) {
405 var p InitializeParams
406 if err := strictDecode(raw, &p); err != nil {
407 return nil, MustProtocolError(ErrInvalidParams)
408 }
409 if err := compareProtocolVersion(p.ProtocolID, p.ProtocolVersion); err != nil {
410 return nil, &fatalError{err: err}
411 }
412 result, err := s.handler.Initialize(ctx, p)
413 if err != nil {
414 s.log.Printf("extension: initialize handler failed: %v", err)
415 return nil, &fatalError{err: err}
416 }
417 if result == nil {
418 return nil, &fatalError{err: errors.New("extension: Initialize returned a nil result")}
419 }
420 result.ProtocolVersion = ProtocolVersion
421 if result.Name == "" {
422 result.Name = s.opts.Name
423 }
424 if result.Version == "" {
425 result.Version = s.opts.Version
426 }
427 if strings.TrimSpace(result.Name) == "" || strings.TrimSpace(result.Version) == "" {
428 return nil, &fatalError{err: errors.New("extension: initialize result requires a name and version")}
429 }
430 if result.StateSchemaVersion < 0 {
431 return nil, &fatalError{err: errors.New("extension: stateSchemaVersion must be non-negative")}
432 }
433 return result, nil
434 }
435
436 // compareProtocolVersion mirrors the host's handshake identity check.
437 func compareProtocolVersion(peerID, peerVersion string) error {
438 if peerID != ProtocolID {
439 return MustProtocolError(ErrUnsupportedVersion)
440 }
441 major, err := strconv.Atoi(peerVersion)
442 if err != nil {
443 return MustProtocolError(ErrProtocolError)
444 }
445 if major != ProtocolMajor {
446 return MustProtocolError(ErrUnsupportedVersion)
447 }
448 return nil
449 }
450
451 func (s *server) handleInitialized(context.Context, json.RawMessage) {
452 // The barrier itself opened in gateNotification, synchronously on the
453 // read loop, so no later frame can overtake it.
454 }
455
456 func (s *server) handleShutdown(ctx context.Context, raw json.RawMessage) (any, error) {
457 var p ShutdownParams
458 if err := strictDecode(raw, &p); err != nil || p.TimeoutMillis < 0 {
459 return nil, MustProtocolError(ErrInvalidParams)
460 }
461 s.shutdownOnce.Do(func() {
462 s.mu.Lock()
463 s.state = stateShutdown
464 s.mu.Unlock()
465 if s.opts.Shutdown != nil {
466 fnCtx := ctx
467 cancel := func() {}
468 if p.TimeoutMillis > 0 {
469 fnCtx, cancel = context.WithTimeout(ctx, time.Duration(p.TimeoutMillis)*time.Millisecond)
470 }
471 defer cancel()
472 done := make(chan struct{})
473 go func() {
474 s.opts.Shutdown(fnCtx)
475 close(done)
476 }()
477 select {
478 case <-done:
479 case <-fnCtx.Done():
480 s.log.Printf("extension: shutdown function did not return within %dms", p.TimeoutMillis)
481 }
482 }
483 })
484 return deferredResult{
485 result: ShutdownResult{Accepted: true},
486 after: func() {
487 // Orderly close: end in-flight calls, then close the read side so
488 // the read loop exits and the host sees EOF when the process
489 // exits. Serve returns nil.
490 s.conn.shutdown(nil)
491 if closer, ok := s.conn.r.(io.Closer); ok {
492 _ = closer.Close()
493 }
494 },
495 }, nil
496 }
497
498 // ---------------------------------------------------------------------------
499 // Intercept and observation
500 // ---------------------------------------------------------------------------
501
502 func (s *server) handleIntercept(ctx context.Context, raw json.RawMessage) (any, error) {
503 var p InterceptParams
504 if err := strictDecode(raw, &p); err != nil {
505 return nil, MustProtocolError(ErrInvalidParams)
506 }
507 if !validInterceptEvent(p.Event) || p.Seq < 1 || p.TimeoutMillis < 0 || !jsonKeyPresent(raw, "payload") {
508 return nil, MustProtocolError(ErrInvalidParams)
509 }
510 payload, err := s.rehydrate(ctx, p.Payload, p.Externalized, "/payload")
511 if err != nil {
512 return nil, err
513 }
514 fn := s.opts.Interceptors[string(p.Event)]
515 if fn == nil {
516 fn = s.opts.Interceptors["*"]
517 }
518 if fn == nil {
519 return Continue(), nil
520 }
521 if p.TimeoutMillis > 0 {
522 var cancel context.CancelFunc
523 ctx, cancel = context.WithTimeout(ctx, time.Duration(p.TimeoutMillis)*time.Millisecond)
524 defer cancel()
525 }
526 result, err := fn(ctx, string(p.Event), payload)
527 if err != nil {
528 // The callback's advertised intercept budget expired. Return the
529 // frozen timeout reason rather than racing the host's identical timer
530 // with a generic internal error response.
531 if errors.Is(err, context.DeadlineExceeded) && errors.Is(ctx.Err(), context.DeadlineExceeded) {
532 return nil, MustProtocolError(ErrInterceptTimeout)
533 }
534 return nil, err
535 }
536 if result == nil {
537 return Continue(), nil
538 }
539 if !validInterceptDecision(result.Decision) {
540 return nil, fmt.Errorf("extension: interceptor for %q returned invalid decision %q", p.Event, result.Decision)
541 }
542 return result, nil
543 }
544
545 func (s *server) handleEvent(ctx context.Context, raw json.RawMessage) {
546 var p EventParams
547 if err := strictDecode(raw, &p); err != nil || !validInterceptEvent(p.Event) || !jsonKeyPresent(raw, "payload") {
548 s.log.Printf("extension: dropping malformed event notification")
549 return
550 }
551 payload, err := s.rehydrate(ctx, p.Payload, p.Externalized, "/payload")
552 if err != nil {
553 s.log.Printf("extension: dropping event %q: %v", p.Event, err)
554 return
555 }
556 if s.opts.Observer != nil {
557 s.opts.Observer(ctx, string(p.Event), payload)
558 }
559 }
560
561 func (s *server) handleResourcesChanged(ctx context.Context, raw json.RawMessage) {
562 var p ResourcesChangedParams
563 if err := strictDecode(raw, &p); err != nil || p.Paths == nil {
564 s.log.Printf("extension: dropping malformed resources/changed notification")
565 return
566 }
567 if s.opts.ResourcesChanged != nil {
568 s.opts.ResourcesChanged(ctx, p.Paths)
569 }
570 }
571
572 // ---------------------------------------------------------------------------
573 // Provider broker
574 // ---------------------------------------------------------------------------
575
576 func (s *server) handleProviderCatalog(ctx context.Context, raw json.RawMessage) (any, error) {
577 if s.opts.Provider == nil {
578 return nil, MustProtocolError(ErrUnknownMethod)
579 }
580 if err := strictDecode(raw, &ProviderCatalogParams{}); err != nil {
581 return nil, MustProtocolError(ErrInvalidParams)
582 }
583 providers, err := s.opts.Provider.Catalog(ctx)
584 if err != nil {
585 return nil, err
586 }
587 if providers == nil {
588 // The wire form requires an array; null fails the host's decoder.
589 providers = []ProviderDescriptor{}
590 }
591 return ProviderCatalogResult{Providers: providers}, nil
592 }
593
594 func (s *server) handleStreamOpen(ctx context.Context, raw json.RawMessage) (any, error) {
595 if s.opts.Provider == nil {
596 return nil, MustProtocolError(ErrUnknownMethod)
597 }
598 var p StreamOpenParams
599 if err := strictDecode(raw, &p); err != nil {
600 return nil, MustProtocolError(ErrInvalidParams)
601 }
602 if p.SeqBase < 0 {
603 return nil, MustProtocolError(ErrInvalidParams)
604 }
605 if err := p.Validate(); err != nil {
606 return nil, MustProtocolError(ErrInvalidParams)
607 }
608 streamCtx, cancel := context.WithCancel(ctx)
609 chunks, err := s.opts.Provider.Stream(streamCtx, StreamRequest{
610 StreamID: p.StreamID,
611 ProviderRef: p.ProviderRef,
612 Model: p.Model,
613 Effort: p.Effort,
614 Request: p.Request,
615 })
616 if err != nil {
617 cancel()
618 s.log.Printf("extension: provider stream %q failed to open: %v", p.StreamID, err)
619 return nil, MustProtocolError(ErrProviderFailed)
620 }
621 if chunks == nil {
622 cancel()
623 return nil, errors.New("extension: provider returned a nil chunk channel")
624 }
625 handle := &streamHandle{cancel: cancel, done: make(chan struct{})}
626 s.streamsMu.Lock()
627 if _, exists := s.streams[p.StreamID]; exists {
628 s.streamsMu.Unlock()
629 cancel()
630 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "duplicate stream id " + p.StreamID}
631 }
632 s.streams[p.StreamID] = handle
633 s.streamsMu.Unlock()
634 return deferredResult{
635 result: StreamOpenResult{Accepted: true},
636 after: func() { go s.pumpStream(streamCtx, p.StreamID, p.SeqBase, chunks, handle) },
637 }, nil
638 }
639
640 func (s *server) handleStreamCancel(_ context.Context, raw json.RawMessage) (any, error) {
641 var p StreamCancelParams
642 if err := strictDecode(raw, &p); err != nil || strings.TrimSpace(p.StreamID) == "" {
643 return nil, MustProtocolError(ErrInvalidParams)
644 }
645 s.streamsMu.Lock()
646 handle := s.streams[p.StreamID]
647 s.streamsMu.Unlock()
648 if handle == nil {
649 return StreamCancelResult{Cancelled: false}, nil
650 }
651 handle.cancel()
652 return StreamCancelResult{Cancelled: true}, nil
653 }
654
655 // pumpStream forwards one provider channel onto the wire: chunks become
656 // stream/chunk notifications with contiguous 1-based seqs (from SeqBase),
657 // and exactly one stream/end closes the stream — clean on channel close,
658 // with error on an error chunk, interrupted on cancel. A cancel processed by
659 // the SDK is never trailed by another chunk.
660 func (s *server) pumpStream(ctx context.Context, streamID string, seqBase int, chunks <-chan StreamChunk, handle *streamHandle) {
661 defer close(handle.done)
662 defer func() {
663 s.streamsMu.Lock()
664 delete(s.streams, streamID)
665 s.streamsMu.Unlock()
666 }()
667 seq := int64(seqBase)
668 if seq < 1 {
669 seq = 1
670 }
671 var lastSeq int64
672 end := StreamEndParams{StreamID: streamID}
673 for {
674 // A cancel must never be trailed by one more chunk, so check before
675 // every receive and again before every send.
676 select {
677 case <-ctx.Done():
678 end.LastSeq, end.Interrupted = lastSeq, true
679 s.sendStreamEnd(&end)
680 return
681 default:
682 }
683 select {
684 case <-ctx.Done():
685 end.LastSeq, end.Interrupted = lastSeq, true
686 s.sendStreamEnd(&end)
687 return
688 case chunk, ok := <-chunks:
689 if !ok {
690 end.LastSeq = lastSeq
691 s.sendStreamEnd(&end)
692 return
693 }
694 if chunk.Type == ChunkError {
695 end.LastSeq = lastSeq
696 end.Error = frozenErrorSpecs[ErrProviderFailed].Message
697 if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" {
698 end.Error = chunk.Error.Message
699 }
700 s.sendStreamEnd(&end)
701 return
702 }
703 if err := chunk.Validate(); err != nil {
704 s.log.Printf("extension: provider stream %q produced an invalid chunk: %v", streamID, err)
705 end.LastSeq = lastSeq
706 end.Error = "the extension provider produced an invalid chunk"
707 s.sendStreamEnd(&end)
708 return
709 }
710 if err := s.conn.notify(MethodExtensionProviderStreamChunk, StreamChunkParams{
711 StreamID: streamID, Seq: seq, Chunk: chunk,
712 }); err != nil {
713 s.log.Printf("extension: provider stream %q could not deliver chunk %d: %v", streamID, seq, err)
714 return
715 }
716 lastSeq = seq
717 seq++
718 }
719 }
720 }
721
722 func (s *server) sendStreamEnd(end *StreamEndParams) {
723 if err := s.conn.notify(MethodExtensionProviderStreamEnd, *end); err != nil {
724 s.log.Printf("extension: provider stream %q could not deliver stream end: %v", end.StreamID, err)
725 }
726 }
727
728 // ---------------------------------------------------------------------------
729 // UI handlers (Host → Extension)
730 // ---------------------------------------------------------------------------
731
732 func (s *server) handleUIAction(ctx context.Context, raw json.RawMessage) (any, error) {
733 if s.opts.UI.Action == nil {
734 return nil, MustProtocolError(ErrUnknownMethod)
735 }
736 var p UIActionParams
737 if err := strictDecode(raw, &p); err != nil || strings.TrimSpace(p.ActionID) == "" || strings.TrimSpace(p.SessionID) == "" {
738 return nil, MustProtocolError(ErrInvalidParams)
739 }
740 if err := s.opts.UI.Action(ctx, p.ActionID, p.Args); err != nil {
741 return UIActionResult{Accepted: false, Message: err.Error()}, nil
742 }
743 return UIActionResult{Accepted: true}, nil
744 }
745
746 func (s *server) handleUISubmit(ctx context.Context, raw json.RawMessage) (any, error) {
747 if s.opts.UI.Submit == nil {
748 return nil, MustProtocolError(ErrUnknownMethod)
749 }
750 var p UISubmitParams
751 if err := strictDecode(raw, &p); err != nil || strings.TrimSpace(p.SurfaceID) == "" ||
752 strings.TrimSpace(p.SessionID) == "" || p.Values == nil {
753 return nil, MustProtocolError(ErrInvalidParams)
754 }
755 if err := s.opts.UI.Submit(ctx, p.SurfaceID, p.Values); err != nil {
756 s.log.Printf("extension: UI submit for surface %q failed: %v", p.SurfaceID, err)
757 return UISubmitResult{Accepted: false}, nil
758 }
759 return UISubmitResult{Accepted: true}, nil
760 }
761
762 // ---------------------------------------------------------------------------
763 // HostUI: Extension → Host UI client
764 // ---------------------------------------------------------------------------
765
766 // HostUI is the sidecar's client for the host's structured UI surfaces. The
767 // zero value is ready to use; every method takes the context of an SDK
768 // callback (interceptor, observer, provider, UI, or shutdown) and fails with
769 // ErrNoConnection otherwise, and with ErrNotReady before the handshake
770 // barrier opens. Surfaces are structured-only by design: there is no way to
771 // send HTML, CSS, JavaScript, or URLs.
772 type HostUI struct{}
773
774 // uiAnswerKey is the field key the host uses for single-field prompts.
775 const uiAnswerKey = "value"
776
777 // PublishStatus publishes or replaces a one-line status surface.
778 func (HostUI) PublishStatus(ctx context.Context, sessionID string, generation uint64, surfaceID string, p UIStatusPayload) error {
779 if strings.TrimSpace(p.Label) == "" {
780 return errors.New("extension: status payload requires a label")
781 }
782 if !validUISeverity(p.Severity) {
783 return fmt.Errorf("extension: invalid severity %q", p.Severity)
784 }
785 return publishSurface(ctx, sessionID, generation, surfaceID, UISurfaceStatus, p)
786 }
787
788 // PublishCard publishes or replaces a rich read-only card surface.
789 func (HostUI) PublishCard(ctx context.Context, sessionID string, generation uint64, surfaceID string, p UICardPayload) error {
790 for i, field := range p.Fields {
791 if strings.TrimSpace(field.Key) == "" {
792 return fmt.Errorf("extension: card field %d requires a key", i)
793 }
794 }
795 for i, action := range p.Actions {
796 if strings.TrimSpace(action.ActionID) == "" || strings.TrimSpace(action.Label) == "" {
797 return fmt.Errorf("extension: card action %d requires an actionId and label", i)
798 }
799 }
800 return publishSurface(ctx, sessionID, generation, surfaceID, UISurfaceCard, p)
801 }
802
803 // PublishForm publishes or replaces an editable form surface; submissions
804 // return through the Options.UI.Submit callback.
805 func (HostUI) PublishForm(ctx context.Context, sessionID string, generation uint64, surfaceID string, p UIFormPayload) error {
806 if err := validateFormPayload(p); err != nil {
807 return err
808 }
809 return publishSurface(ctx, sessionID, generation, surfaceID, UISurfaceForm, p)
810 }
811
812 // PublishNotification publishes a transient toast-style message.
813 func (HostUI) PublishNotification(ctx context.Context, sessionID string, generation uint64, surfaceID string, p UINotificationPayload) error {
814 if strings.TrimSpace(p.Title) == "" {
815 return errors.New("extension: notification payload requires a title")
816 }
817 if !validUISeverity(p.Severity) {
818 return fmt.Errorf("extension: invalid severity %q", p.Severity)
819 }
820 return publishSurface(ctx, sessionID, generation, surfaceID, UISurfaceNotification, p)
821 }
822
823 func publishSurface(ctx context.Context, sessionID string, generation uint64, surfaceID string, kind UISurfaceKind, payload any) error {
824 s := serverFrom(ctx)
825 if s == nil {
826 return ErrNoConnection
827 }
828 if strings.TrimSpace(surfaceID) == "" || strings.TrimSpace(sessionID) == "" {
829 return errors.New("extension: surfaceId and sessionId are required")
830 }
831 raw, err := json.Marshal(payload)
832 if err != nil {
833 return fmt.Errorf("extension: marshal %s payload: %w", kind, err)
834 }
835 resultRaw, err := s.callHost(ctx, MethodHostUIPublish, UIPublishParams{
836 SurfaceID: surfaceID, SessionID: sessionID, Generation: generation, Kind: kind, Payload: raw,
837 })
838 if err != nil {
839 return err
840 }
841 var result UIPublishResult
842 if err := strictDecode(resultRaw, &result); err != nil {
843 return &ProtocolError{Reason: ErrProtocolError, Message: "invalid host/ui/publish result"}
844 }
845 if !result.Accepted {
846 return fmt.Errorf("extension: host rejected the %s surface %q", kind, surfaceID)
847 }
848 return nil
849 }
850
851 // InputPrompt configures RequestInput.
852 type InputPrompt struct {
853 Title string
854 Message string
855 Label string
856 Default string
857 Required bool
858 }
859
860 // SelectPrompt configures RequestSelect.
861 type SelectPrompt struct {
862 Title string
863 Message string
864 Label string
865 Options []string
866 Default string
867 Required bool
868 }
869
870 // MultiSelectPrompt configures RequestMultiSelect.
871 type MultiSelectPrompt struct {
872 Title string
873 Message string
874 Label string
875 Options []string
876 Required bool
877 }
878
879 // RequestConfirm blocks on a yes/no prompt; the bool is the user's answer.
880 // A dismissed prompt returns ErrUICancelled.
881 func (h HostUI) RequestConfirm(ctx context.Context, sessionID string, generation uint64, surfaceID, message string) (bool, error) {
882 form := UIFormPayload{
883 Message: message,
884 Fields: []UIFormField{{Key: uiAnswerKey, Label: message, Kind: UIFieldConfirm}},
885 }
886 values, err := h.requestPrompt(ctx, sessionID, generation, surfaceID, UIRequestConfirm, form)
887 if err != nil {
888 return false, err
889 }
890 answer, _ := values[uiAnswerKey].(bool)
891 return answer, nil
892 }
893
894 // RequestInput blocks on a free-text prompt and returns the entered text.
895 func (h HostUI) RequestInput(ctx context.Context, sessionID string, generation uint64, surfaceID string, p InputPrompt) (string, error) {
896 field := UIFormField{Key: uiAnswerKey, Label: p.Label, Kind: UIFieldInput, Required: p.Required}
897 if p.Default != "" {
898 field.Default = p.Default
899 }
900 values, err := h.requestPrompt(ctx, sessionID, generation, surfaceID, UIRequestInput, UIFormPayload{
901 Title: p.Title, Message: p.Message, Fields: []UIFormField{field},
902 })
903 if err != nil {
904 return "", err
905 }
906 answer, _ := values[uiAnswerKey].(string)
907 return answer, nil
908 }
909
910 // RequestSelect blocks on a single-choice prompt and returns the picked
911 // option.
912 func (h HostUI) RequestSelect(ctx context.Context, sessionID string, generation uint64, surfaceID string, p SelectPrompt) (string, error) {
913 if len(p.Options) == 0 {
914 return "", errors.New("extension: select prompt requires options")
915 }
916 field := UIFormField{Key: uiAnswerKey, Label: p.Label, Kind: UIFieldSelect, Options: p.Options, Required: p.Required}
917 if p.Default != "" {
918 field.Default = p.Default
919 }
920 values, err := h.requestPrompt(ctx, sessionID, generation, surfaceID, UIRequestSelect, UIFormPayload{
921 Title: p.Title, Message: p.Message, Fields: []UIFormField{field},
922 })
923 if err != nil {
924 return "", err
925 }
926 answer, _ := values[uiAnswerKey].(string)
927 return answer, nil
928 }
929
930 // RequestMultiSelect blocks on a multi-choice prompt and returns the picked
931 // options.
932 func (h HostUI) RequestMultiSelect(ctx context.Context, sessionID string, generation uint64, surfaceID string, p MultiSelectPrompt) ([]string, error) {
933 if len(p.Options) == 0 {
934 return nil, errors.New("extension: multiselect prompt requires options")
935 }
936 field := UIFormField{Key: uiAnswerKey, Label: p.Label, Kind: UIFieldMultiselect, Options: p.Options, Required: p.Required}
937 values, err := h.requestPrompt(ctx, sessionID, generation, surfaceID, UIRequestMultiselect, UIFormPayload{
938 Title: p.Title, Message: p.Message, Fields: []UIFormField{field},
939 })
940 if err != nil {
941 return nil, err
942 }
943 switch answer := values[uiAnswerKey].(type) {
944 case []string:
945 return answer, nil
946 case []any:
947 out := make([]string, 0, len(answer))
948 for _, item := range answer {
949 text, ok := item.(string)
950 if !ok {
951 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "host/ui/request multiselect answer is not a string list"}
952 }
953 out = append(out, text)
954 }
955 return out, nil
956 case nil:
957 return []string{}, nil
958 default:
959 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "host/ui/request multiselect answer is not a string list"}
960 }
961 }
962
963 // RequestForm blocks on a fully custom form prompt and returns all values
964 // keyed by field key. It is the structured escape hatch behind the typed
965 // prompt helpers.
966 func (h HostUI) RequestForm(ctx context.Context, sessionID string, generation uint64, surfaceID string, form UIFormPayload) (map[string]any, error) {
967 if err := validateFormPayload(form); err != nil {
968 return nil, err
969 }
970 return h.requestPrompt(ctx, sessionID, generation, surfaceID, UIRequestInput, form)
971 }
972
973 func (h HostUI) requestPrompt(ctx context.Context, sessionID string, generation uint64, surfaceID string, kind UIRequestKind, form UIFormPayload) (map[string]any, error) {
974 s := serverFrom(ctx)
975 if s == nil {
976 return nil, ErrNoConnection
977 }
978 if strings.TrimSpace(surfaceID) == "" || strings.TrimSpace(sessionID) == "" {
979 return nil, errors.New("extension: surfaceId and sessionId are required")
980 }
981 raw, err := json.Marshal(form)
982 if err != nil {
983 return nil, fmt.Errorf("extension: marshal %s payload: %w", kind, err)
984 }
985 resultRaw, err := s.callHost(ctx, MethodHostUIRequest, UIRequestParams{
986 SurfaceID: surfaceID, SessionID: sessionID, Generation: generation, Kind: kind, Payload: raw,
987 })
988 if err != nil {
989 return nil, err
990 }
991 var result UIRequestResult
992 if err := strictDecode(resultRaw, &result); err != nil {
993 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "invalid host/ui/request result"}
994 }
995 if result.Cancelled {
996 return nil, ErrUICancelled
997 }
998 return result.Values, nil
999 }
1000
1001 func validateFormPayload(p UIFormPayload) error {
1002 if p.Fields == nil {
1003 return errors.New("extension: form payload requires a fields array (possibly empty)")
1004 }
1005 for i, field := range p.Fields {
1006 if strings.TrimSpace(field.Key) == "" {
1007 return fmt.Errorf("extension: form field %d requires a key", i)
1008 }
1009 if !validUIFieldKind(field.Kind) {
1010 return fmt.Errorf("extension: form field %q has invalid kind %q", field.Key, field.Kind)
1011 }
1012 }
1013 return nil
1014 }
1015
1016 // ---------------------------------------------------------------------------
1017 // Content refs (Extension → Host)
1018 // ---------------------------------------------------------------------------
1019
1020 // ReadContentRef pages one whole content ref back from the host in
1021 // ContentRefChunkBytes chunks, verifies the reassembled byte count and
1022 // SHA-256 against the host's own report, and fails on any inconsistency. An
1023 // expired or unknown ref returns a *ProtocolError with Reason
1024 // ErrContentRefExpired.
1025 func ReadContentRef(ctx context.Context, ref string) ([]byte, error) {
1026 s := serverFrom(ctx)
1027 if s == nil {
1028 return nil, ErrNoConnection
1029 }
1030 if strings.TrimSpace(ref) == "" {
1031 return nil, errors.New("extension: content ref is required")
1032 }
1033 var out []byte
1034 var offset int64
1035 for {
1036 raw, err := s.callHost(ctx, MethodHostContentRead, ContentReadParams{ContentRef: ref, Offset: offset})
1037 if err != nil {
1038 return nil, err
1039 }
1040 var result ContentReadResult
1041 if err := strictDecode(raw, &result); err != nil {
1042 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "invalid host/content/read result"}
1043 }
1044 if result.ContentRef != ref || result.Offset != offset {
1045 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "host/content/read answered a different ref or offset"}
1046 }
1047 if result.Encoding != ContentUTF8 {
1048 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "host/content/read answered with an unknown encoding"}
1049 }
1050 if result.TotalBytes > ContentRefObjectBytes {
1051 return nil, &ProtocolError{Reason: ErrFrameTooLarge, Message: fmt.Sprintf(
1052 "content ref is %d bytes, above the %d byte object cap", result.TotalBytes, ContentRefObjectBytes)}
1053 }
1054 chunk, err := base64.StdEncoding.DecodeString(result.DataBase64)
1055 if err != nil {
1056 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "host/content/read returned invalid base64"}
1057 }
1058 if len(chunk) > ContentRefChunkBytes {
1059 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "host/content/read returned an oversized chunk"}
1060 }
1061 out = append(out, chunk...)
1062 if result.NextOffset == nil {
1063 if int64(len(out)) != result.TotalBytes {
1064 return nil, &ProtocolError{Reason: ErrProtocolError, Message: fmt.Sprintf(
1065 "content ref reassembled to %d bytes, host reported %d", len(out), result.TotalBytes)}
1066 }
1067 sum := sha256.Sum256(out)
1068 if !strings.EqualFold(hex.EncodeToString(sum[:]), result.SHA256) {
1069 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "content ref SHA-256 mismatch"}
1070 }
1071 return out, nil
1072 }
1073 if *result.NextOffset <= offset {
1074 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "host/content/read made no progress"}
1075 }
1076 offset = *result.NextOffset
1077 }
1078 }
1079
1080 // ResolveExternalized rehydrates one owner document's externalizable field.
1081 // raw is the field's inline value and externalized the owner's envelope, at
1082 // the schema-registered JSON pointer ("/payload" for intercept and event
1083 // params, "/replacement" for intercept results). With an empty envelope the
1084 // inline value passes through; otherwise the envelope must hold exactly the
1085 // pointer's descriptor, and the ref is paged back and verified against the
1086 // descriptor's byte count and SHA-256 before it is returned. An inline value
1087 // alongside an envelope, a wrong pointer, or unverifiable content is a
1088 // protocol error — never decode bytes the peer did not prove.
1089 //
1090 // Intercept and event payloads are resolved automatically before the
1091 // interceptor/observer runs; this helper remains for manual use.
1092 func ResolveExternalized(ctx context.Context, raw json.RawMessage, externalized []ExternalizedField, pointer string) (json.RawMessage, error) {
1093 if serverFrom(ctx) == nil {
1094 return nil, ErrNoConnection
1095 }
1096 return resolveExternalized(ctx, raw, externalized, pointer)
1097 }
1098
1099 func (s *server) rehydrate(ctx context.Context, raw json.RawMessage, externalized []ExternalizedField, pointer string) (json.RawMessage, error) {
1100 return resolveExternalized(ctx, raw, externalized, pointer)
1101 }
1102
1103 func resolveExternalized(ctx context.Context, raw json.RawMessage, externalized []ExternalizedField, pointer string) (json.RawMessage, error) {
1104 if len(externalized) == 0 {
1105 return raw, nil
1106 }
1107 if inline := bytes.TrimSpace(raw); len(inline) > 0 && !bytes.Equal(inline, []byte("null")) {
1108 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "document carries both an inline value and an externalized envelope"}
1109 }
1110 if len(externalized) != 1 || externalized[0].JSONPointer != pointer {
1111 return nil, &ProtocolError{Reason: ErrProtocolError, Message: fmt.Sprintf(
1112 "externalized envelope must hold exactly the %s descriptor", pointer)}
1113 }
1114 descriptor := externalized[0]
1115 if descriptor.TotalBytes > ContentRefObjectBytes {
1116 return nil, &ProtocolError{Reason: ErrFrameTooLarge, Message: fmt.Sprintf(
1117 "externalized value is %d bytes, above the %d byte object cap", descriptor.TotalBytes, ContentRefObjectBytes)}
1118 }
1119 data, err := ReadContentRef(ctx, descriptor.ContentRef)
1120 if err != nil {
1121 return nil, err
1122 }
1123 if int64(len(data)) != descriptor.TotalBytes {
1124 return nil, &ProtocolError{Reason: ErrProtocolError, Message: fmt.Sprintf(
1125 "externalized value reassembled to %d bytes, want %d", len(data), descriptor.TotalBytes)}
1126 }
1127 sum := sha256.Sum256(data)
1128 if !strings.EqualFold(hex.EncodeToString(sum[:]), descriptor.SHA256) {
1129 return nil, &ProtocolError{Reason: ErrProtocolError, Message: "externalized value SHA-256 mismatch"}
1130 }
1131 return data, nil
1132 }
1133
1134 // ---------------------------------------------------------------------------
1135 // shared helpers
1136 // ---------------------------------------------------------------------------
1137
1138 // callHost issues one Extension → Host request behind the handshake barrier
1139 // and maps a structured wire error back to a *ProtocolError.
1140 func (s *server) callHost(ctx context.Context, method string, params any) (json.RawMessage, error) {
1141 if err := s.checkReady(); err != nil {
1142 return nil, err
1143 }
1144 raw, err := s.conn.call(ctx, method, params)
1145 if err != nil {
1146 return nil, mapCallError(err)
1147 }
1148 return raw, nil
1149 }
1150
1151 // mapCallError converts a peer's JSON-RPC error into a *ProtocolError when it
1152 // carries a frozen reason.
1153 func mapCallError(err error) error {
1154 var respErr *ResponseError
1155 if errors.As(err, &respErr) {
1156 var data ProtocolErrorData
1157 if len(respErr.Data) > 0 && json.Unmarshal(respErr.Data, &data) == nil && data.Validate() == nil {
1158 return &ProtocolError{Reason: data.Reason, Message: respErr.Message}
1159 }
1160 }
1161 return err
1162 }
1163
1164 // strictDecode decodes one params/result document rejecting unknown fields
1165 // and trailing JSON, mirroring the host's strict decoder envelope rules.
1166 func strictDecode(raw json.RawMessage, v any) error {
1167 if len(bytes.TrimSpace(raw)) == 0 {
1168 raw = json.RawMessage(`{}`)
1169 }
1170 decoder := json.NewDecoder(bytes.NewReader(raw))
1171 decoder.DisallowUnknownFields()
1172 if err := decoder.Decode(v); err != nil {
1173 return err
1174 }
1175 var extra any
1176 if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
1177 return errors.New("trailing JSON")
1178 }
1179 return nil
1180 }
1181
1182 // jsonKeyPresent reports whether raw is an object containing key, for
1183 // required-but-nullable fields such as the externalizable payload.
1184 func jsonKeyPresent(raw json.RawMessage, key string) bool {
1185 var object map[string]json.RawMessage
1186 if err := json.Unmarshal(raw, &object); err != nil {
1187 return false
1188 }
1189 _, ok := object[key]
1190 return ok
1191 }
1192
1192 lines GO