plugins

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 12, 2026 License: Apache-2.0 Imports: 35 Imported by: 0

Documentation

Overview

Package plugins runs third-party TypeScript plugins (spec 5.3–5.5): manifest validation, the sandbox and the ctx API. It does not import core; the host side is injected.

Index

Constants

View Source
const (
	MaxBodyBytes    = 256 << 10 // each of a message's text and html, as a plugin gets it
	MaxStorageValue = 64 << 10
	MaxStorageTotal = 1 << 20  // keys and values of one plugin
	MaxLLMBytes     = 64 << 10 // llm.complete prompt and answer; llm.decide state and question text
	MaxQuestions    = 128      // per llm.decide call: rejected beyond, never split
	MaxFetchBody    = 1 << 20

	EventReceived   = "message.received"
	EventBeforeSend = "message.beforeSend"
)

Host-side data limits (spec 5.5).

View Source
const (
	MaxFileBytes  = 5 << 20
	MaxTotalBytes = 20 << 20
)

Snapshot limits.

View Source
const (
	MaxFetchResponse = 5 << 20
	FetchTimeout     = 30 * time.Second
)

Network limits of ctx.net.fetch (spec 5.5).

View Source
const BuildDir = ".build"

BuildDir is where a snapshot keeps its compiled code; it is not part of the snapshot.

View Source
const MaxResultBytes = 1 << 20

MaxResultBytes caps the JSON a plugin call hands back to the host.

Variables

View Source
var (
	ErrPermission  = errors.New("plugin: permission denied")
	ErrTimeout     = errors.New("plugin: call timed out")
	ErrMemoryLimit = errors.New("plugin: memory limit exceeded")
	ErrClosed      = errors.New("plugin: closed")
	ErrTooLarge    = errors.New("plugin: data too large")
)

Functions

func Build

func Build(dir, main string) ([]byte, error)

Build compiles the snapshot in dir with ESBuild, reusing a cached build.

func BuildWith

func BuildWith(c Compiler, dir, main string) ([]byte, error)

BuildWith compiles the snapshot in dir with c. The result is cached in dir/.build under the snapshot's hash, main and c.Key(); other builds there are removed.

func EgressFor

func EgressFor(kind InvocationKind, m Manifest, granted, holdsMail, scopeReadMail bool) (hosts []string, needsApproval bool)

EgressFor is the egress rule for one call of kind into plugin m (plan 4 规格解释): the hosts its Invocation may fetch from, and whether the call needs an egress approval listing them first. granted: the plugin's current permissions are granted; holdsMail: the plugin was ever handed mail content (its storage may keep it, across calls, updates and restarts); scopeReadMail: the call's input may carry mail content (a Wit run that has read mail calling a tool, an action on a selection). Init, card and beforeSend never fetch; a hook gets every declared host; an action or a tool needs approval when the plugin holds net and any of mail:read, holdsMail or scopeReadMail. Since ctx.storage outlives a call, "this call read no mail" is never trusted for a plugin that has or ever had mail.

func FailReason

func FailReason(err error) string

FailReason is the audit reason of a request the policy allowed that failed: dns_error, tls_error or network_error.

func HashDir

func HashDir(dir string) (string, error)

HashDir hashes the files of a plugin directory as Snapshot does.

func HostPort

func HostPort(u *url.URL) string

HostPort is u's lowercased host with its port, the scheme's default (https 443, http 80) when it has none: the destination an audit row names.

func QuestionBytes

func QuestionBytes(qs []Question) int

QuestionBytes is the size of the questions' text: instructions, values and descriptions.

func ScrubPaths

func ScrubPaths(s string) string

ScrubPaths replaces absolute paths in s with "<path>".

func Snapshot

func Snapshot(src, dst string) (sourceHash string, err error)

Snapshot copies the plugin directory src to dst, which must not exist yet, and returns the hash of the copy. It copies into a sibling temp directory and renames that to dst, so dst is complete or absent.

Types

type ActionInfo

type ActionInfo struct {
	ID    string
	Label Text
	On    []string
}

type Answer

type Answer struct {
	Name        string   `json:"name"`
	Choice      string   `json:"choice,omitempty"`
	Probability *float64 `json:"probability,omitempty"`
	Score       *float64 `json:"score,omitempty"`
	Refused     bool     `json:"refused,omitempty"`
}

Answer is a validated answer: yes_no sets Probability, choice sets Choice (and Probability when the judge gives one), score sets Score, an index into Levels. Refused answers carry nothing else.

type AttachmentMeta

type AttachmentMeta struct {
	Name string `json:"name"`
	Type string `json:"type"`
	Size int64  `json:"size"`
}

type CallScope

type CallScope struct {
	// contains filtered or unexported fields
}

CallScope is one top-level call. It is only valid on the owner goroutine, inside the Call that made it (including host functions JS invokes then).

func (*CallScope) Async

func (c *CallScope) Async(fn func(ctx context.Context) (any, error)) goja.Value

Async returns a promise settled by fn, which runs on its own goroutine with the call's context. The result goes through rt.ToValue (json tags name struct fields); an error rejects with a plain Error whose message is redacted and path-scrubbed. The settlement is posted back to the owner goroutine tagged with this call; if the call has ended by then it is dropped and never runs.

func (*CallScope) Context

func (c *CallScope) Context() context.Context

Context ends with the call; synchronous host work should honour it, since Interrupt cannot stop a running Go function.

func (*CallScope) Invocation

func (c *CallScope) Invocation() Invocation

func (*CallScope) Runtime

func (c *CallScope) Runtime() *goja.Runtime

Runtime is the goja runtime; use it only on the owner goroutine during the call.

func (*CallScope) VM

func (c *CallScope) VM() *VM

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is ctx.net.fetch's HTTP client. It resolves names itself and dials only addresses it has checked (no DNS rebinding between check and connect), never uses a proxy, and re-checks every redirect.

func NewClient

func NewClient(opts NetOptions) *Client

func (*Client) Do

func (c *Client) Do(ctx context.Context, allowed []string, req FetchRequest) (FetchResponse, []Hop, error)

Do fetches req if its URL is allowed (host[:port], 443 by default) and returns the response, every hop for the audit (none only for an unparseable URL), and an error; a policy refusal or the timeout is a *NetError. No partial body is ever returned.

func (*Client) Transport

func (c *Client) Transport() *http.Transport

Transport is the client's transport, for a caller with its own request policy (an MCP server): no proxy, and only addresses that pass the IP rules.

type Clock

type Clock interface {
	AfterFunc(d time.Duration, f func()) (stop func() bool)
}

Clock schedules the call deadlines and the watchdog ticks; tests inject a fake one.

type Compiler

type Compiler interface {
	// Key identifies the compiler version and options: builds cached under
	// another key are not reused.
	Key() string
	Compile(dir, main string) ([]byte, error)
}

Compiler turns a snapshot into the script the plugin VM runs.

var ESBuild Compiler = esbuildCompiler{}

ESBuild bundles TypeScript or JavaScript with esbuild.

type ConfigField

type ConfigField struct {
	Key         string          `json:"key"`
	Type        string          `json:"type"`
	Label       Text            `json:"label"`
	Options     []Option        `json:"options,omitempty"`
	AllowCustom bool            `json:"allowCustom,omitempty"`
	Required    bool            `json:"required,omitempty"`
	Default     json.RawMessage `json:"default,omitempty"`
	When        *When           `json:"when,omitempty"`
}

ConfigField is one setting the user fills in. Type is text, secret, number, boolean or select. A secret has no default and is stored in the keychain.

func (ConfigField) Accepts

func (f ConfigField) Accepts(v json.RawMessage) bool

Accepts reports whether v is a value f can hold (not for a secret).

type ConfigSnapshot

type ConfigSnapshot struct {
	Values  map[string]any
	Version int64
	Err     error
}

ConfigSnapshot is a plugin's configuration at one config version; Err is why it could not be read (ctx.config then throws). Never changed once made.

type DraftInput

type DraftInput struct {
	AccountID int64    `json:"accountId"`
	To        []string `json:"to"`
	Cc        []string `json:"cc"`
	Subject   string   `json:"subject"`
	HTML      string   `json:"html"`
}

type DraftUpdate

type DraftUpdate struct {
	DraftID  int64  `json:"draftId"`
	Revision int64  `json:"revision"`
	HTML     string `json:"html"`
}

DraftUpdate replaces a draft's body if the draft is still at Revision.

type FetchRequest

type FetchRequest struct {
	URL     string            `json:"url"`
	Method  string            `json:"method"`
	Headers map[string]string `json:"headers"`
	Body    string            `json:"body"`
}

type FetchResponse

type FetchResponse struct {
	Status  int               `json:"status"`
	Headers map[string]string `json:"headers"`
	Body    string            `json:"body"`
}

type Hop

type Hop struct{ Dest, Decision, Reason string }

Hop is one request of a fetch, the first or a redirect: its host:port and its outcome. Decision is "allow" (answered, or redirected onward), "deny" (refused by the policy; Reason is the NetError code) or "error" (the policy allowed it and it failed; Reason is dns_error, network_error, tls_error, timeout or cancelled).

type Host

type Host interface {
	StorageGet(ctx context.Context, key string) (string, bool, error)
	StorageSet(ctx context.Context, key, value string) error
	StorageDelete(ctx context.Context, key string) error
	MailGet(ctx context.Context, id int64) (MailMessage, error)
	MailSearch(ctx context.Context, q string, limit int) ([]MailSummary, error)
	MailSetFlags(ctx context.Context, ids []int64, add, remove []string) error
	MailMove(ctx context.Context, ids []int64, folderID int64) error
	MailCreateDraft(ctx context.Context, d DraftInput) (int64, error)
	MailUpdateDraft(ctx context.Context, u DraftUpdate) (int64, error)
	LLMComplete(ctx context.Context, prompt string) (string, error)
	LLMDecide(ctx context.Context, state string, qs []Question) ([]Answer, error)
	Fetch(ctx context.Context, inv Invocation, req FetchRequest) (FetchResponse, error)
	AuditEgress(ctx context.Context, inv Invocation, dest, decision, reason string) error
}

Host is what the core gives a plugin's ctx. Permissions are checked before any of it is called. It has no way to send mail, and must never get one.

type Invocation

type Invocation struct {
	ID          string
	Kind        InvocationKind
	Write       bool
	EgressHosts []string
	// Unapproved: the call needed an egress approval it did not get, so it
	// has no hosts; a fetch to a declared host is denied as approval_required.
	Unapproved bool
	// Approved: the user approved this call's egress (its hosts were listed).
	// An action or tool call without it is checked again as it fetches.
	Approved bool
	// Config is the call's ctx.config, taken when the host admitted the call
	// (before it waits for the VM); nil makes ctx.config throw.
	Config *ConfigSnapshot
}

Invocation is what the host allows one top-level call into a plugin: whether it may write mail, and the hosts it may fetch from (none: no egress). What the plugin declared when registering does not widen it.

type InvocationKind

type InvocationKind string
const (
	KindInit       InvocationKind = "init"
	KindHook       InvocationKind = "hook"
	KindBeforeSend InvocationKind = "beforeSend"
	KindAction     InvocationKind = "action"
	KindCard       InvocationKind = "card"
	KindTool       InvocationKind = "tool"
)

type MailMessage

type MailMessage struct {
	ID          int64            `json:"id"`
	Subject     string           `json:"subject"`
	From        string           `json:"from"`
	To          []string         `json:"to"`
	Cc          []string         `json:"cc"`
	Date        time.Time        `json:"date"`
	Text        string           `json:"text"`
	HTML        string           `json:"html"`
	Truncated   bool             `json:"truncated"`
	Attachments []AttachmentMeta `json:"attachments"`
}

MailMessage is a message as a plugin sees it. Text and HTML are cut to the host's limit, with Truncated set.

type MailSummary

type MailSummary struct {
	ID      int64     `json:"id"`
	Subject string    `json:"subject"`
	From    string    `json:"from"`
	Date    time.Time `json:"date"`
	Snippet string    `json:"snippet"`
}

type Manifest

type Manifest struct {
	Name        string        `json:"name"`
	Version     string        `json:"version"`
	Description Text          `json:"description"` // one line, shown under the name in settings
	Main        string        `json:"main"`
	Permissions Permissions   `json:"permissions"`
	Config      []ConfigField `json:"config"`
}

Manifest is a plugin's plugin.json (spec 5.3–5.4).

func ParseManifest

func ParseManifest(data []byte, dirName string) (Manifest, error)

ParseManifest parses and strictly validates plugin.json of the plugin installed in the directory dirName. A manifest that fails is not loaded.

func (Manifest) PermissionHash

func (m Manifest) PermissionHash() string

PermissionHash identifies what the user granted: it changes when the mail, net or llm permissions change, and not with their order, case, a ":443" or the config.

type NetError

type NetError struct{ Reason, Host string }

NetError is a fetch the policy refused. Reason is a stable code: host_not_allowed, scheme, ip_literal, userinfo, private_address, too_many_redirects, too_large or timeout.

func (*NetError) Error

func (e *NetError) Error() string

type NetOptions

type NetOptions struct {
	Resolver interface {
		LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
	}
	Dial                     func(ctx context.Context, network, addr string) (net.Conn, error)
	AllowHTTP, AllowLoopback bool
	MaxBody                  int64         // response body; 0 = MaxFetchResponse
	Timeout                  time.Duration // whole call, redirects and body included; 0 = FetchTimeout
	UserAgent                string        // "Godwit-Plugin/<version>"
	// contains filtered or unexported fields
}

NetOptions configures a Client. Zero values are the real network and the spec's limits; AllowHTTP and AllowLoopback exist for tests only.

type Option

type Option struct {
	Value string `json:"value"`
	Label Text   `json:"label"`
}

type PermissionError

type PermissionError struct{ Perm string }

PermissionError is a ctx API call the manifest or the invocation does not allow. Its message names only the permission.

func (*PermissionError) Error

func (e *PermissionError) Error() string

func (*PermissionError) Is

func (e *PermissionError) Is(target error) bool

type Permissions

type Permissions struct {
	Mail []string `json:"mail"`
	Net  []string `json:"net"`
	LLM  bool     `json:"llm"`
}

Permissions: Mail holds "read" and/or "write" (there is no send); Net the hosts, each "host" or "host:port", lowercased and without ":443" after ParseManifest.

type Plugin

type Plugin struct {
	Name string
	// contains filtered or unexported fields
}

Plugin is a loaded plugin: its VM and what it registered during initialization. Registrations never change after Load.

func Load

func Load(ctx context.Context, vm *VM, m Manifest, h Host, cfg *ConfigSnapshot) (*Plugin, error)

Load builds the ctx object and runs the plugin's initialization in an init invocation (no writes, no egress, cfg as ctx.config), recording what it registers.

func (*Plugin) Actions

func (p *Plugin) Actions() []ActionInfo

func (*Plugin) Card

func (p *Plugin) Card(ctx context.Context, inv Invocation, m MailMessage) (res any, matched bool, err error)

Card runs the card's match(m) and, if its result is truthy, render(m), both under inv, and returns render's result; matched is false when match was not truthy. ctx bounds both calls together.

func (*Plugin) Emit

func (p *Plugin) Emit(ctx context.Context, inv Invocation, event string, args ...any) error

Emit runs the plugin's handler for event with args (plain JSON-like Go data) as one top-level call under inv, awaiting it if it is async.

func (*Plugin) EmitResult

func (p *Plugin) EmitResult(ctx context.Context, inv Invocation, event string, args ...any) (any, error)

EmitResult is Emit that returns the handler's (awaited) result as plain JSON data.

func (*Plugin) Events

func (p *Plugin) Events() []string

Events lists the events it handles, sorted.

func (*Plugin) Handles

func (p *Plugin) Handles(event string) bool

Handles reports whether the plugin registered a handler for event.

func (*Plugin) HasCard

func (p *Plugin) HasCard() bool

func (*Plugin) RunAction

func (p *Plugin) RunAction(ctx context.Context, inv Invocation, id string, target any) (any, error)

RunAction runs the registered action id on target under inv and returns its (awaited) result as plain JSON data.

func (*Plugin) RunTool

func (p *Plugin) RunTool(ctx context.Context, inv Invocation, name string, args any) (any, error)

RunTool runs the registered tool name with args under inv and returns its (awaited) result as plain JSON data.

func (*Plugin) Tools

func (p *Plugin) Tools() []ToolInfo

func (*Plugin) VM

func (p *Plugin) VM() *VM

type Question

type Question struct {
	Name         string           `json:"name"`
	Type         string           `json:"type"`
	Instructions string           `json:"instructions"`
	Choices      []QuestionOption `json:"choices,omitempty"`
	Levels       []QuestionOption `json:"levels,omitempty"` // lowest first
}

Question is a typed judge question, as llm.decide and Wit's classify_mail take it. Type is yes_no, choice or score; Choices and Levels are its options.

type QuestionOption

type QuestionOption struct {
	Value       string `json:"value"`
	Description string `json:"description,omitempty"`
}

type Text

type Text struct {
	Default string
	ByLang  map[string]string
}

Text is a label: a plain string, or {"default": ..., "<lang>": ...}.

func (Text) For

func (t Text) For(lang string) string

For returns the text for lang, then for its base language ("de" for "de-AT"), then the default.

func (Text) MarshalJSON

func (t Text) MarshalJSON() ([]byte, error)

func (*Text) UnmarshalJSON

func (t *Text) UnmarshalJSON(b []byte) error

type ToolInfo

type ToolInfo struct {
	Name, Description string
	Schema            map[string]any
	Write             bool
}

ToolInfo is a registered tool; Write defaults to true.

type VM

type VM struct {
	// contains filtered or unexported fields
}

VM is one plugin's goja runtime. Every touch of the runtime happens on its owner goroutine: top-level calls are queued and run one at a time there, and async host work posts its completion back to it. Nothing returned by Call is a goja.Value.

func NewVM

func NewVM(name string, program *goja.Program, opts VMOptions) *VM

NewVM creates the runtime and its owner goroutine; it runs no plugin code.

func (*VM) Call

func (v *VM) Call(ctx context.Context, inv Invocation, fn func(c *CallScope) (any, error)) (any, error)

Call runs fn on the owner goroutine as one top-level call, after any call already running or queued. If fn returns a goja.Value it is awaited when it is a promise (pumping only this call's async completions) and exported as plain JSON data (map[string]any, []any, float64, string, bool, nil; at most MaxResultBytes, else ErrTooLarge). Go results are normalized the same way through encoding/json; one holding a goja.Value, func or channel is an error. The call ends at settlement, ctx's end, Close (ErrClosed), the kind's time limit (ErrTimeout) or the watchdog (ErrMemoryLimit); JS still running is interrupted.

func (*VM) Close

func (v *VM) Close()

Close refuses new calls (ErrClosed), cancels the running one and waits for it, drops pending completions and stops the owner goroutine.

func (*VM) HostFunc

func (v *VM) HostFunc(name string, fn func(c *CallScope, call goja.FunctionCall) goja.Value) goja.Value

HostFunc wraps a host function for JS. Call it on the owner goroutine (from inside a Call or Init). fn gets the scope of the call JS invoked it in. To throw, fn panics; JS always gets a plain Error (no Go value behind it, so e.value is undefined) whose message went through Redact and path scrubbing:

  • an error: "<name>: " + its message
  • a goja error value (rt.NewTypeError(...), ...): its message, keeping TypeError as TypeError
  • anything else, Go runtime errors included: "<name>: internal error"

Interrupts and stack overflows pass through untouched.

func (*VM) Init

func (v *VM) Init(ctx context.Context, inv Invocation, jsCtx func(c *CallScope) goja.Value) error

Init runs the bundle (the IIFE that defines `plugin`) and then plugin.default(jsCtx(c)) as one call, awaiting it if it is async. A nil jsCtx passes undefined.

type VMOptions

type VMOptions struct {
	Clock    Clock
	Watchdog *Watchdog
	Redact   func(string) string
}

VMOptions: a nil Clock is the real clock, a nil Watchdog the default one, a nil Redact the identity.

type Watchdog

type Watchdog struct {
	Meter     func() uint64
	Clock     Clock
	Interval  time.Duration
	Threshold uint64
}

Watchdog is the soft heap limit: goja has no per-runtime memory cap, so while a call runs it samples a process-wide meter and interrupts that call with ErrMemoryLimit when the meter has grown by more than Threshold since the call began. ponytail: process-wide, so it cannot tell the plugin's allocations from the app's; a child process with GOMEMLIMIT is the hard cap if one is ever needed.

Zero fields take defaults: Meter reads runtime.MemStats.HeapAlloc, Clock is the VM's, Interval 10 ms, Threshold 256 MB.

func (*Watchdog) Watch

func (w *Watchdog) Watch(v *VM, callID uint64) (release func())

Watch samples until release and can only ever abort callID: a tick after release, or while another call runs, does nothing.

type When

type When struct {
	Key       string          `json:"key"`
	Equals    json.RawMessage `json:"equals,omitempty"`
	NotEquals json.RawMessage `json:"notEquals,omitempty"`
}

When shows a field only if the earlier field Key equals (or does not equal) a value of that field's type.

func (*When) Match

func (w *When) Match(v json.RawMessage) bool

Match reports whether the condition holds for v, the referenced field's JSON value (nil when unset). Values compare by type and value: "true" never equals true, 3.0 equals 3.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL