Skip to content

feat: run plugins — WebAssembly, digest-pinned, installed with godo -e plugins - #7

Merged
MY-RV merged 14 commits into
mainfrom
feat/plugin-poc
Sep 21, 2026
Merged

MY-RV merged 14 commits into
mainfrom
feat/plugin-poc

Conversation

@MY-RV

@MY-RV MY-RV commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Summary

Stacked on #6, which is stacked on #5. This is the plugin half.

A plugin is a WASI command: a program with a main(), compiled to wasm. godo writes one JSON request to its stdin, answers the ops it writes back, and takes its exit code as the script's. No memory-sharing ABI, any language that targets WASI, and a plugin is testable as an ordinary program:

echo '{"api":1,"body":"echo hi","argv":{},"args":[]}' | ./plugin
engine:
  plugins:
    - source: https://github.com/MY-RV/godo-micropy/releases/download/v0.3.0/godo-micropy.wasm
      sha256: "7759ac…"
      provides: [runner:micropy]

scripts:
  # @runner micropy
  worktree create ${BRANCH} ${DIR}: ${godo:file(./worktree.godo.py)}
$ godo -e plugins install https://github.com/MY-RV/godo-micropy/releases/download/v0.3.0/godo-micropy.wasm
$ godo worktree create wt/example ../wt-example
worktree ready: ../wt-example (wt/example)

What is pinned, and what is not

The sha256. A plugin is third-party code that runs when someone types godo test, and which artifact runs is worth answering.

What a plugin may do is not pinned. An earlier draft gated ops behind a config permission list; it was removed. A catalog's scripts already run with the shell's full reach — godo test has always been sh -c with no sandbox — and a plugin body is a script in that same catalog, written by the same people. Worse, the guarantee was not real: exec is the only door out of a wasm guest, so granting it granted everything. config is the plugin's own block now, carried verbatim; godo reads only fs.mount, which is configuration, not permission — a guest cannot mount anything itself.

${godo:file(path)}

A script body can live in a file, so Python gets an editor that understands it. Inclusion rather than expansion: it happens when the catalog is read, works for every runner, and an included body then behaves exactly as if pasted.

Not in this PR

  • Windows is unverified beyond cross-compiling and go vet.
  • No lockfile. engine.plugins pins by digest; there is no resolution.
  • The example plugin is built at test time rather than committed — a checked-in binary is something nobody can review.

Checklist

  • Behavior change? Update docs/contract.md in the same PR
  • Tests cover the change (happy path + edge)
  • ./godo ci passes locally
  • No drive-by refactors

…ilities

engine.plugins declared what a catalog expects and nothing read it. This loads
the artifact, verifies it, and registers the runners it provides.

A plugin is a WASI command: a normal program with a main(), compiled to wasm.
That choice is the whole design. There is no memory-sharing ABI to get right,
any language targeting WASI can write one, and a plugin is testable as an
ordinary program — pipe it a request on stdin and read what it says. The
example plugin has no wasm-specific code in it at all.

godo writes one JSON request, the plugin writes JSON ops, godo answers the ones
that need answering, and the plugin's exit code is the script's. The sandbox is
wazero's default, which is nothing: no filesystem, network, environment or
clock. A plugin reaches the outside world only by asking, and only what
config grants is honoured — deny by default, where an absent section, an absent
key, or anything that is not exactly true, is not granted.

The digest is checked before the bytes reach a compiler. 'this is the artifact
that was reviewed' is the one thing a catalog can assert about third-party code
that runs when someone types 'godo test', so it is checked first and a mismatch
stops the run.

Preview is the plugin's to answer. godo can render a shell line because it
wrote it; it cannot render a program it does not interpret, so it asks with
mode preview and prints what comes back. That also means a plugin holding
proc.exec could run things during a preview — documented, because the honest
statement is that this is a trust boundary and not a mechanism.

A plugin body is not expanded: ${godo:…} is shell-space syntax, and the values
it would paste in travel on the invocation as data. That is what lets a plugin
script read a capture by name, and it is why InvocationRunner exists beside
Runner rather than replacing it — a shell runner still only ever wanted the
finished line.

wazero is pinned to v1.9.0, the last release whose own go directive matches
godo's 1.22 floor, and it brings no dependencies of its own.
Linking wazero put Apache-2.0 code inside every binary godo releases, and
Apache-2.0 asks that its licence and NOTICE travel with any form of
distribution. The archives were shipping LICENSE alone, which was fine while
godo was MIT and stdlib and stopped being fine the moment a dependency arrived.

THIRD_PARTY_LICENSES.md carries wazero's NOTICE and licence verbatim, and the
archive now lists its files explicitly instead of relying on GoReleaser's
default glob — the default would not have picked this up, and a default that
silently decides what ships with a binary is not something to lean on for a
licence obligation.

Nothing about the licence of godo itself changes: it stays MIT.
Adds the two sentences that were missing from the notices file: every licence
in it is permissive, so none asks godo to change its own; and none of them
reaches into a plugin, because a plugin is a separate artifact from a separate
repository that godo does not ship. What a plugin carries is that repository's
inventory to publish next to its own artifact.
…ig names

Two things that were the same mistake. --preview used to start the plugin in a
'preview mode' and print what it chose to emit, which meant a plugin holding
proc.exec could run commands during a preview and godo could only ask it not
to. And the sandbox was described as shut without a way to open it, so a
MicroPython body could not read the file next to the godo.yaml it belongs to.

--preview now prints the body and does not start the plugin at all. A plugin
body is a program, and the only faithful answer to 'what will this do' without
running it is the program itself. Anything else is a guess, and a guess is
useful but deserves its own flag rather than quietly borrowing this one — that
is a later --predict. The mode field and the emit op are removed rather than
left unused; plugins ignore fields they do not know, so --predict can add them
back without breaking anything written today.

The sandbox still starts shut — measured from inside a plugin with an empty
config, the filesystem is ENOENT, the environment is empty, the clock is frozen
at 2022, and there is no socket module. config now names what comes back:
proc.exec for the op, fs.mount for a filesystem, time.wall for the real clock.

fs.mount is a bool, not a path. What gets mounted is the directory the
godo.yaml lives in, as the guest's root, so a script reaches the catalog it
belongs to and nothing above it. A catalog that chose its own mount point could
ask for / and the grant would mean nothing.

go.mod moves to 1.26.0, the oldest Go release still receiving security fixes.
The 1.22 it declared has been out of support for a while, and wazero v1.12 —
the only version with the WebAssembly exception handling that a C interpreter
compiled for WASI needs — made that visible rather than caused it.
A plugin's script printing had nowhere to go: stdout is the protocol channel,
so a print would corrupt it. The out op carries a line to the host's stdout and
is deliberately one-way — a plugin that waited for an answer would hang. It
needs no capability, because printing is not a side effect on the machine.

slink is the one filesystem operation a mounted directory does not solve.
os.symlink is not portable: Unix makes a symlink and Windows wants a junction,
and a script cannot paper over that itself. It needs config fs.slink.

Paths resolve against the catalog's directory but are not confined to it. The
first draft confined them, which broke the case the op exists for: a git
worktree is created beside a repository, not inside it, and linking .env into
it is the whole point. Confinement would also have been theatre — proc.exec can
run 'ln -s' anywhere, so a slink narrower than exec protects nothing. The grant
is the boundary; withhold it from a plugin you would not hand a shell.

An absolute path is taken as given rather than reinterpreted: a script naming
/tmp means /tmp.
Declaring a plugin and having it on this machine are different things. The
catalog says which artifact a script needs; there was nothing that put it here.

install fetches, stores it under the user cache keyed by its digest, and writes
the entry into godo.yaml. The digest is never asked for — it is computed from
what the artifact turns out to contain. A person cannot check a hash by reading
it, so asking someone to type one is how wrong hashes get committed.

The catalog is spliced, not re-encoded. A godo.yaml is written by hand: its
comments carry @deps and @runner, its blank lines group scripts, and a block
scalar's whitespace is content. Round-tripping through a YAML encoder would
keep the data and lose the file, so the parser is used only to find where to
write — which is what its line numbers are for.

A file beside the catalog is loaded from where it is. Asking someone to install
what they can already see is ceremony, and the digest is checked either way.
Anything else must be installed first: a run is not the moment to discover that
something has to be downloaded.

A relative source resolves against the directory it was written in — the
catalog's for a declared plugin, the caller's own for one typed on a command
line — so ./x.wasm means the same thing from a subdirectory.

http:// is refused. An artifact is code, and its integrity cannot rest on a
transport anyone on the path can rewrite: a wrong digest is a failure you see,
a silently swapped download and a swapped catalog is not.

Codex was given internal/pluginstore and stopped to ask instead of guessing: the
brief said not to re-download when the digest is already present, but a digest
is only known after downloading. It was right, and the answer is the split
between Fetch, which always reads, and Ensure, which can skip.
… grants

Installing over an existing declaration appended a second entry, and the loader
then refused the catalog: two entries claiming one runner. The ordinary case is
the one that broke — an example catalog ships a placeholder digest precisely so
install can write the real one.

UpsertPlugin replaces the entry that provides the same names, and keeps the
config block that was already there. What a plugin may do is the catalog
author's decision, reached once and usually narrowed; rewriting it to a default
on every install would quietly re-grant what someone took away and drop what
they added. Only the source and the digest move.
A script inside a shut sandbox fails in the language's own words. Reading a
file that is not there is ENOENT, and nothing in that says the catalog never
granted a filesystem — the connection is invisible from inside the guest, which
is where the message comes from.

The failure now carries the grants beside it. It does not claim to know why
something failed, because it does not: it puts what was available next to what
went wrong, and lets the reader join them. When fs.mount is missing it says so
outright, since a script that touches files is the common case and the error it
produces looks like a bug in the script.
fs.mount granted exactly one directory: the one holding the godo.yaml. A script
that needs /tmp, or a shared cache, or anything beside the repository could not
have it — and the limitation was mine, not wasm's. wazero has always been able
to mount several; the single mount was a decision that then got defended as
though it were the nature of the sandbox.

It now takes a list, or a mapping when the guest path should differ from the
host one, and keeps the bool for the common case of wanting only the catalog.
Nothing is widened by default: a directory the config does not name does not
exist inside, and no path climbs out of one that it does.

The protocol doc now also says what config is, because it was described only by
what it grants. It is a closed list of four keys. Everything else that does not
work — network, fork, subprocess — is not config's doing and could not be:
WASI has no syscall for any of them, so proc.exec is not godo being strict, it
is the only door there is.
godo was refusing ops a catalog had not granted, and the whole arrangement
defended nothing. A catalog's scripts already run with the shell's full reach —
godo test has always been sh -c with no sandbox — and a plugin body is a script
in that same catalog, written by the same people. There was nobody to defend it
from.

Worse, the guarantee was not real even on its own terms: proc.exec is the only
door out of a wasm guest, so granting it granted everything. A script that can
run one command can run sh, and fs.mount became decoration next to
'cat /etc/passwd'. Making config mean something would have taken an allowlist
of commands — more surface, so that people could defend themselves from scripts
they wrote.

config goes back to being what it was described as before it drifted: the
plugin's own block, carried across verbatim, its keys and their meaning
belonging to whoever reads them. godo reads exactly one thing, fs.mount, and
that is configuration rather than permission — a guest cannot mount anything
itself, so somebody has to say what it sees. Omitted, it gets the directory its
godo.yaml lives in, because a script that cannot read the repository it belongs
to is not useful.

What remains is the part that answers a real question: the sha256. A plugin is
third-party code, and which artifact runs is worth pinning. What it may do once
it runs is not, and saying otherwise was theatre.
Python inside YAML fights the editor: no highlighting, no linting, no
formatter, and indentation that belongs to two languages at once. A body that
is exactly ${godo:file(path)} comes from that file instead.

The syntax is not new. ${godo:…} is already the namespace godo claims in a
body and already the only one it claims, so there is nothing to collide with —
which a 'file:' prefix on the value could not say.

It is inclusion, not expansion. It happens when the body is read rather than
when it is rendered, which is why it works for a plugin runner whose bodies are
never expanded, and why ${godo:…} inside the included file is left alone: that
text belongs to whatever runs it, not to godo.

Only a whole value is accepted. Splicing a file into part of a line would paste
newlines into a shell command and mean something different every time.

The path resolves against the godo.yaml rather than the caller's directory: a
script says where its body lives, and that should not change with where godo
was run from.

--ls now indents every line of a body, since one can be a file.
The first description claimed ${godo:…} inside an included file is left alone.
That is false under shell, where an included body expands like any other —
checked by running both forms and comparing. What is true is narrower: the
inclusion happens when the catalog is read, so it works for every runner, and
what happens to the text afterwards is the runner's business and unchanged.

A test now asserts the property directly: the same text included and pasted
produce the same line.
Splitting a script into files broke the protocol. The plugin arranges a print
for the script's own globals, but an imported module has its own, holding the
language's builtin, which writes to the stdout godo reads ops from. godo then
failed the run on an unreadable op — for a script that had done nothing
stranger than putting a function in another file.

A line that is not an op is now output. It counts as an op only if it parses as
JSON and names one godo knows: scripts print JSON constantly, and a printed
data structure that happens to have an 'op' field would otherwise be obeyed
instead of shown.

A line that is a valid op is still obeyed whoever printed it. Closing that
needs a secret on every op, which was written and then reverted: it hangs every
plugin already built to answer without one, and it is a worse failure than the
coincidence it prevents — inside a catalog whose scripts can call exec
directly.
A wasm guest has no sockets, so a plugin's only route to the network was to
exec something that has them — curl, or whatever the machine happens to carry.
That is precisely the platform dependency a plugin exists to remove: a script
that works here and not on a teammate's laptop because one of them installed a
tool is the problem, not a workaround for it. godo makes the request with Go's
client, which is the same on every platform it ships to.

The body comes back base64 rather than as a JSON string. Measured before the
change: asking for 1024 bytes of binary returned 988, silently — a JSON string
replaces whatever is not valid UTF-8, so an image arrives shorter than it left
with nothing raised. Encoding costs a third on the wire and cannot lose a byte.

Requests time out after thirty seconds unless asked otherwise. Without a
deadline a script hangs on a server that never answers, with nothing to read
and nothing to kill.

A non-2xx is a status, not an error, like a failed command's exit code.

And an op godo does not recognise is now answered with an error instead of
printed. Passing unknown lines through as output was right for a script's
print; for an op it means a plugin waits for a reply that never comes, and a
hang is a worse failure than a stray line.
@MY-RV
MY-RV changed the base branch from feat/runner-axis to main September 21, 2026 04:05
@MY-RV
MY-RV merged commit a467864 into main Sep 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant