feat: run plugins — WebAssembly, digest-pinned, installed with godo -e plugins - #7
Merged
Merged
Conversation
MY-RV
force-pushed
the
feat/runner-axis
branch
from
September 21, 2026 04:02
6e5e342 to
5cff8fe
Compare
4 tasks
…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
force-pushed
the
feat/plugin-poc
branch
from
September 21, 2026 04:05
12d2057 to
fe36daa
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:What is pinned, and what is not
The
sha256. A plugin is third-party code that runs when someone typesgodo test, and which artifact runs is worth answering.What a plugin may do is not pinned. An earlier draft gated ops behind a
configpermission list; it was removed. A catalog's scripts already run with the shell's full reach —godo testhas always beensh -cwith no sandbox — and a plugin body is a script in that same catalog, written by the same people. Worse, the guarantee was not real:execis the only door out of a wasm guest, so granting it granted everything.configis the plugin's own block now, carried verbatim; godo reads onlyfs.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
go vet.engine.pluginspins by digest; there is no resolution.Checklist
./godo cipasses locally