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