Help us shape the future of Go! Take the 2026 Go Developer Survey

plugin

package
v0.0.20 Latest Latest
Warning

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

Go to latest
Published: Sep 29, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package plugin runs a builtin whose implementation is in another process, in whatever language its author chose.

The design is docs/design/plugins.md and the rule it is written around is the one to check any change here against:

A plugin adds no capability. It relocates one across a process boundary.

So every method below corresponds to a seam this repository already publishes — interp.Builtin, and the handful of *interp.Runner accessors a registered builtin genuinely cannot do without — and to nothing else. There is no plugin/openFile and no plugin/runCommand: a plugin is already free to open whatever it likes, and offering it a route through us would add inward attack surface to the process holding the script's variables without removing any freedom it had.

The transport, and the second one

The transport is JSON-RPC 2.0 over the plugin's own standard input and output — internal/jsonrpc, the framing internal/acp already drove three non-Go agents over. The whole of what a plugin has to do is read lines of JSON from standard input and write lines of JSON to standard output, which is a page in any language on the machine.

It was chosen over gRPC, which is what #738 asked for, and docs/design/plugins.md carries the measured argument: `go list -deps ./cmd/sh` names zero packages outside the standard library and this module, so gRPC and protobuf would be the first runtime dependencies this repository has ever had; they would add a code generator to the build; and they would put HTTP/2 framing in front of the plugin author most likely to show up, who is writing a short script. The claim that reaching other languages needs gRPC is falsified thirty lines away rather than argued against.

A second transport is expected — "JSON-RPC now, gRPC later" is the maintainer's decision, 2026-09-06 — and there is deliberately no abstraction here for it. Two transports invented before either has a plugin would be shaped by neither, which is #801's warning one layer up: a seam ends up shaped by its first remote consumer rather than by its callers.

What would have to be true to add one, so that the argument is made against evidence rather than made again from scratch: a real plugin needing typed streaming or generated stubs, or a measured cost this encoding is paying. The place it would go is Launch, whose result is a *Host — a Host is a declared surface plus a way to make a call, and neither the message shapes above nor anything in command.go names a wire format. Two things would move: the base64 in outputParams and readResult, which exists because JSON strings are UTF-8 and a shell's streams are bytes, and the framing itself. Nothing about the surface, the security model or the lifetime rules is transport business, and a second transport that changed one of those would be a different design rather than the same one spoken differently.

Index

Constants

View Source
const (
	// MethodInitialize is the handshake, and the host speaks first. A plugin
	// that dies before saying anything is then a launch failure with a
	// diagnostic, rather than a host blocked on a first message that will
	// never come.
	MethodInitialize = "initialize"
	// MethodInvoke runs one of the plugin's commands.
	MethodInvoke = "command/invoke"
	// MethodCancel says the shell has given up on a call. A notification
	// rather than a request: there is nothing to answer, and the call it names
	// is going to fail either way. What it buys the plugin is the chance to
	// stop cleanly before the host kills it.
	MethodCancel = "command/cancel"
	// MethodEvent is one record of the shell's event stream, for a plugin that
	// declared the observer role.
	//
	// A notification, and it is the only message in this protocol that names no
	// call — because an event names no call. Events come from every goroutine a
	// shell has and from subshells the plugin has never been told about, so
	// there is nothing for a call id to mean here. What identifies a record is
	// what identifies it everywhere else in this repository: the session and
	// the action id it carries, which is the pair internal/event owns.
	//
	// The params are an event.Record, unwrapped: the observer role sends
	// exactly the records the JSON Lines schema already defines rather than a
	// second shape meaning the same thing. That is the whole reason this role
	// is nearly free — internal/event was written as a shared contract rather
	// than as a feature of one consumer, and this is the third consumer taking
	// it as it stands.
	MethodEvent = "observer/event"
	// MethodPromptContext says what the next prompt is being drawn for, for a
	// plugin that declared the segment role.
	//
	// A notification, and it is **told rather than asked** — which is the
	// whole of how the segment role clears the hot-path exclusion in
	// docs/design/plugins.md. A request would put a process boundary on the
	// path between pressing return and seeing a prompt; a notification the
	// shell does not wait for puts nothing there at all.
	//
	// At most one is ever waiting, and a plugin that is behind loses the ones
	// it was behind on rather than making the shell queue them. That is not a
	// weaker guarantee than the observer role's bounded buffer, it is a
	// different question: an event is a record and losing one is a gap, while
	// a context is a *state* and the newest one is the only one that means
	// anything. The prompt after this one says the same thing again.
	MethodPromptContext = "prompt/context"
)

The methods the host calls on a plugin.

View Source
const (
	// MethodGetVar reads a shell variable. The environment a plugin inherited
	// is the environment at the moment it was launched; the shell's variables
	// are the Runner's and change under it.
	MethodGetVar = "shell/getVar"
	// MethodSetVar writes a shell variable. This is the reason Register
	// exists at all: `read` has to put a value into a variable of the calling
	// shell, and nothing a child process can do reaches it.
	MethodSetVar = "shell/setVar"
	// MethodDir is the shell's working directory, which is the Runner's and
	// not the process's, so a plugin resolving a relative path has no other
	// way to ask.
	MethodDir = "shell/dir"
	// MethodRead reads from the stream the command's standard input is
	// pointing at, which under a pipeline or a redirection is not the
	// process's.
	MethodRead = "shell/read"
	// MethodDiagnose writes a complaint located and named the dialect's way,
	// rather than an unattributed line on standard error.
	MethodDiagnose = "shell/diagnose"
	// MethodOutput is a chunk of what the command wrote. A notification, so
	// that a plugin streams output without waiting for an acknowledgement per
	// chunk — and handled on the read loop in order, which is what makes
	// `echo one; echo two` arrive in that order.
	MethodOutput = "command/output"
	// MethodPromptSegment is a plugin saying what one of its segments now
	// holds. A notification, from the plugin, whenever it has something —
	// nothing asked for it and nothing is waiting for it.
	//
	// The host stores it and says a redraw would differ; the session decides
	// whether anything is actually redrawn, and a prompt that renders the
	// same is not written to the screen. So a plugin that publishes a
	// thousand times while a command runs costs one redraw, and a plugin that
	// never publishes costs a segment that draws nothing.
	MethodPromptSegment = "prompt/segment"
)

The methods a plugin calls on the host. Each is here because a process that is not this one has no other way to ask, and the list is deliberately shorter than *interp.Runner.

View Source
const (
	StreamOut = "out"
	StreamErr = "err"
)

The two streams a chunk of output can be for. A plugin's own standard error is a third thing and is not this: the host relays that, prefixed, whether or not a call is in flight.

View Source
const Version = 1

Version is the protocol revision this host speaks.

One integer, and a mismatch is a refusal rather than a negotiation — see Host.handshake. That is the policy format's `version 1` rule read at the protocol layer: a policy half-understood allows what it was written to refuse, and a protocol half-understood produces a builtin that does something other than what the script asked for.

Variables

View Source
var ErrRefused = errors.New("refused by the policy")

ErrRefused is what Launch returns when the gate refuses to run the plugin. Distinguishable because a caller says a different sentence about it: a policy that does not name the plugin is a policy doing its job, and a plugin that crashed at startup is a plugin that is broken.

Functions

This section is empty.

Types

type Host

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

Host is one launched plugin: its declared surface, and the connection its builtins are calls on.

Safe for concurrent use, which is not optional. A background job and each half of a pipeline run on their own goroutines, so two calls into one plugin are the ordinary case rather than a stress test — the same warning interp.Gate carries, where "the first gate written against this package was a closure appending to a slice, and it had a data race the moment a script said `&`."

func Launch

func Launch(ctx context.Context, o Options) (*Host, error)

Launch starts a plugin, hands it the handshake, and returns a host holding whatever it declared.

The path must be absolute and there is no PATH search. A searched name is a plugin whose identity depends on a variable, and PATH is a variable a script sets — so a searched plugin would be arbitrary code chosen by whoever set the variable, which is the failure the policy format's same requirement exists to prevent.

Launching a plugin is an exec and it passes the exec gate, through internal/boundary, before the process exists. Under a default-deny policy a plugin does not start unless the policy names it, in the same way the policy must already permit reading the script. That is correct rather than inconvenient: the person who wrote the policy is the person who named the plugin.

The gate is consulted once, here, and not once per call. A call is not an exec — no process is created by it — and a gate consultation per call would be a decision the policy has no new information to make.

One residual is recorded rather than implied. Boundary.Exec answers and the caller then spawns, so between the answer and the exec the path could be replaced. That gap is exec's and not the plugin's: it is the same shape as every other ActionExec in this tree, and closing it needs an exec from a verified descriptor, which is not portable — there is no fexecve on darwin. #966 closed the equivalent gap for opens, where the kernel can be asked where a descriptor went; nothing here can ask the same question of an exec.

func (*Host) Close

func (h *Host) Close() error

Close shuts the plugin down and reaps it. Idempotent, and safe to call while calls are in flight — a call in flight is a call that is about to fail, which is the honest answer for a shell that is ending.

The sequence is fixed and each step is here because the one before it does not always work:

  1. Let the observer feed, if there is one, put the records it has already numbered on the wire, bounded by flushWait; then close the plugin's input. A cooperating plugin sees EOF and exits, which is how every well-behaved one goes. The bound is what stops a plugin that has stopped reading from choosing how long a shutdown takes, and closing the input is what unblocks a feed already parked in a write to one.

  2. Wait a bound for that to happen.

  3. SIGKILL the process group. Not impatience — see killGroup. This is what makes a blocked read on the plugin's output return, and killing the group is what takes anything the plugin started with it.

  4. Wait for the reaper, which is unbounded and can be: SIGKILL cannot be caught, so the process goes.

  5. Take the read streams away, which ends the two loops even if something outside the group inherited a write end. Belt and braces, and **not** covered: a write end held outside the process group needs a process that left it, and there is no portable way to arrange one from a fixture — `setsid` is not on darwin. It stays because the failure it prevents is a goroutine parked for the life of the shell.

    The two streams are not taken away together, and that asymmetry is #1070. Standard error carries the message a plugin died with, and the process is already reaped by this point, so the relay reaches end of input on its own: it is given that chance before its stream goes, and the close is what happens if it does not come. Doing both at once discarded the diagnostic on 8 launches in 500 on Linux and 0 in 500 on Darwin — the same bug on both, and only the odds differ.

func (*Host) Commands

func (h *Host) Commands() []string

Commands is every name the plugin claimed, in the order it claimed them.

func (*Host) Err

func (h *Host) Err() error

Err is why this plugin is no longer usable, or nil while it is.

func (*Host) Handle

func (h *Host) Handle(_ context.Context, method string, params json.RawMessage) (any, error)

Handle answers the host methods, which are the small part of the Runner a plugin genuinely cannot do without.

Everything not below is deliberately absent, and each absence has a reason rather than being a gap somebody has not got to:

  • Register and Unregister at call time. A plugin rewriting the command table while a script runs is a shell whose `type` answer depends on what has already run. Names are declared at the handshake and fixed.
  • Expand. Expansion includes command substitution, so it is `eval` by proxy: it re-enters the interpreter with input a foreign process chose, while a builtin call from that process is in flight. Every re-entrancy hazard in the interpreter, reachable from outside it, to save a plugin author a getVar.
  • LookPath, WriterForFd, NamedOption, FunctionText. Each exists for a specific in-process builtin, none is needed to write a command, and a surface is easier to widen later than to narrow.
  • Anything that opens a file or runs a command. A plugin is already free to do both with its own descriptors; a route through us would add inward attack surface to the process holding the script's variables without removing any freedom it had. This is where the design deliberately differs from docs/design/acp.md, which offers an agent fs/read_text_file precisely so the access comes through our gate — an ACP client is constraining an agent, and there is nothing here to constrain.
  • Answering a gate consultation. The rate is wrong by orders of magnitude — a glob stats every entry it descends past — and there is no third answer available: fail closed and a hung plugin bricks the shell, fail open and `kill -9` on the plugin is a privilege escalation. An embedder who wants a remote decision writes an interp.Gate in Go that calls out however it likes, and owns its timeout.

A method this host does not have is answered -32601, which is a fact and not a failure: the plugin records it and carries on. That is what makes the surface widenable without a version bump.

func (*Host) Name

func (h *Host) Name() string

Name is the plugin's declared name, or the base of its path until it has declared one.

func (*Host) Notify

func (h *Host) Notify(_ context.Context, method string, params json.RawMessage)

Notify takes what a plugin sends with nobody to answer: output.

Handled on the read loop, in order, which is the transport's contract and is what makes `echo one; echo two` from a plugin arrive in that order. So this must not block on anything but the stream it is writing to — and blocking on that is correct, because it is exactly what an in-process builtin writing to a full pipe does.

A notification for a call that has ended is dropped in silence. There is nobody to tell, which is what a notification means.

func (*Host) Path

func (h *Host) Path() string

Path is the executable this host launched.

func (*Host) PromptSegments added in v0.0.11

func (h *Host) PromptSegments() repl.PromptSegmentSource

PromptSegments is the repl.PromptSegmentSource this plugin's segment role supplies, or nil for a plugin that did not declare one.

Nil rather than an empty source, and it is the same typed-nil care Sink needs: returning s.segs directly when it is nil would hand back an interface holding a nil pointer, which is not nil, and every caller's `if source != nil` would be wrong.

func (*Host) Register

func (h *Host) Register(r *interp.Runner)

Register installs a stub for each of the plugin's declared commands.

Composed into driver.Shell.Register by the front end, after the dialect's own registrations, so a plugin may replace a builtin a dialect installed. docs/design/plugins.md flags that as the maintainer's to reverse; it follows from the conservation rule, which says the plugin surface is Register's surface, and Register permits replacement.

Called once per Runner, at startup, before the first command word is resolved. That timing is not an optimization either: a name that appeared partway through a run would make `type foo` answer differently depending on what had already run.

func (*Host) Sink

func (h *Host) Sink() interp.Sink

Sink is the interp.Sink that feeds this plugin's observer role, or nil if it did not declare one.

Nil rather than a sink that discards, and it is the difference between two sentences a front end says: a nil Sink is what interp already means by "no consumer", so a plugin that did not ask for the event stream costs the shell nothing at all rather than costing it a conversion per action into a channel nobody reads.

The typed-nil hazard is why this is a method and not a field. Returning h.obs directly when it is nil would hand back an interface holding a nil *observer, which is not nil, and every caller's `if sink != nil` would be wrong.

type Options

type Options struct {
	// Path is the plugin executable, and it must be absolute. See Launch.
	Path string

	// Bound is the shell's gate and audit sink, for the one access launching a
	// plugin makes: the exec. A zero Boundary allows everything and records
	// nothing, which is what a shell without a policy is.
	Bound boundary.Boundary

	// Stderr is where the plugin's own standard error is relayed, prefixed.
	// Nil discards it, which no shipped caller does — see the relay.
	Stderr io.Writer
}

Options is what launching a plugin needs.

Jump to

Keyboard shortcuts

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