Load a WASM plugin into the coder chat, and prove it live

16d3fa582629 · AtlantisPleb · · parent 10e26c1f4a01

Load a WASM plugin into the coder chat, and prove it live

The one-off working demo ahead of the plugin walking skeleton (#26),
under the contract in openagents.com docs
2026-08-24-triage-and-plugin-model-assessment.md sections 4.2-4.4.

- plugins/word-stats: a dependency-free Rust guest compiled to
  wasm32-unknown-unknown, checked in as artifact plus source, with a
  manifest carrying identity, a sha256 digest pin, the packet ABI export
  names, typed input/output JSON Schemas, and capability declarations.
- coder-plugins.ts: the host. Digest verified before compile, imports
  proven empty before instantiate (pure compute only), one
  worker_threads worker per invocation terminated at the manifest's
  timeout, and every failure a typed refusal returned as a value.
- /plugin load <manifest> in the interface and --plain registers the
  manifest as a session-scoped CoderTool; the model calls it by name and
  the run renders in the transcript. Experimental, session-only.
- scripts/plugin-demo.mjs walks load -> verify -> invoke -> refusals ->
  timeout from the shell; test/coder-plugins.test.ts proves the same
  paths plus a --plain transcript through CoderSession. Proved against a
  live thread model calling word_stats in openagents coder --plain.
- docs/plugins/2026-08-24-coder-plugin-demo-shape.md records the ABI and
  memory mechanics, what the skeleton keeps versus replaces, and the
  open questions (WASI, fuel, memory ceiling, Effect integration).

Also gives the coder-ui skills test fixture the auto field CoderSkill
already requires, which main's typecheck was failing on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added docs/plugins/2026-08-24-coder-plugin-demo-shape.md
  • added packages/openagents-cli/scripts/plugin-demo.mjs
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-plain.ts
  • added packages/openagents-cli/src/coder-plugins.ts
  • modified packages/openagents-cli/src/coder-ui.ts
  • added packages/openagents-cli/test/coder-plugins.test.ts
  • modified packages/openagents-cli/test/coder-ui.test.ts
  • added plugins/word-stats/.gitignore
  • added plugins/word-stats/Cargo.lock
  • added plugins/word-stats/Cargo.toml
  • added plugins/word-stats/README.md
  • added plugins/word-stats/manifest.json
  • added plugins/word-stats/src/lib.rs
  • added plugins/word-stats/word_stats.wasm

Diff

15 files changed, +1263 -37

docs/plugins/2026-08-24-coder-plugin-demo-shape.md added +156

@@ -0,0 +1,156 @@

1
# The coder plugin demo, and the shape it recommends
2
3
2026-08-24. Branch `coder-plugin-demo`. This is the one-off working demo
4
ahead of the plugin walking skeleton (#26): a WASM plugin loads into
5
`openagents coder`, its digest is verified before load, and a live model
6
calls it as a tool in the chat. The contract it follows is
7
`OpenAgentsInc/openagents.com` docs
8
`2026-08-24-triage-and-plugin-model-assessment.md` sections 4.2–4.4.
9
10
## What was built
11
12
- `plugins/word-stats/` — a guest plugin in dependency-free Rust, compiled
13
  to `wasm32-unknown-unknown` (rustc 1.94.1), with the 52 KB artifact and
14
  its SHA-256 checked in beside the source so the demo runs without a Rust
15
  toolchain. It computes text statistics: bytes, chars, words, lines,
16
  longest word, most frequent word. Pure computation — the module imports
17
  nothing.
18
- `plugins/word-stats/manifest.json` — identity (name, version, author),
19
  the artifact path and `sha256:` digest pin, the ABI export names, typed
20
  input and output JSON Schemas, capability declarations (empty mounts,
21
  empty hosts, a 2000 ms timeout, a memory figure), and reserved
22
  `price_msats` / `license` fields per the assessment.
23
- `packages/openagents-cli/src/coder-plugins.ts` — the host. Plain
24
  `WebAssembly` API, no new dependencies. Load: parse and validate the
25
  manifest, refuse non-empty capability declarations, read the artifact,
26
  compare SHA-256 to the pin, compile, refuse any module whose import list
27
  is not empty, require the declared exports. Invoke: one
28
  `node:worker_threads` worker per call, terminated at the manifest's
29
  timeout. Every failure is a typed refusal `{code, reason}` returned as a
30
  value, never thrown.
31
- `/plugin load <manifest>` in both the interface and `--plain`. A loaded
32
  plugin materializes one session-scoped `CoderTool`: the manifest's name
33
  is the tool name, its description is the tool description (suffixed
34
  "experimental, session-only, sandboxed"), its input schema is the tool
35
  parameters, and `run` marshals the JSON arguments to a packet and the
36
  output packet back to text.
37
- `scripts/plugin-demo.mjs` — the happy path and all three refusal paths
38
  from the shell, and `test/coder-plugins.test.ts` — nine tests including a
39
  `--plain` transcript driven through `CoderSession` by a scripted source.
40
41
Proved live: `printf '/plugin load ../../plugins/word-stats/manifest.json\n
42
<prompt>\n' | node dist/main.js coder --plain` — the thread model called
43
`word_stats`, the call and its `→ ok` rendered in the transcript, and the
44
reply was built from the plugin's output.
45
46
## ABI and memory mechanics
47
48
The packet contract is `handle_packet(bytes) -> bytes`, and bytes cross the
49
boundary through guest linear memory:
50
51
1. The host calls the guest's exported allocator `packet_alloc(len) -> ptr`
52
   and writes the input packet — the UTF-8 JSON encoding of the tool
53
   arguments — into guest memory at `ptr`.
54
2. The host calls `handle_packet(ptr, len)`. The guest allocates its output
55
   inside its own memory and returns one `u64` packing the location:
56
   `(out_ptr << 32) | out_len`. (WASM `i64` returns surface as `BigInt` in
57
   Node; the worker unpacks with shifts.)
58
3. The host re-reads `memory.buffer` after the call — the guest may have
59
   grown memory, which detaches earlier views — bounds-checks
60
   `out_ptr + out_len`, and copies the output packet out.
61
62
The output buffer is deliberately leaked by the guest: the instance lives
63
for exactly one call, so a `packet_free` export would be ceremony. That
64
choice is coupled to one-worker-per-invocation, which also buys the two
65
properties the contract cares about: the timeout is enforceable (a WASM
66
call is synchronous and cannot be preempted in-process, so the host
67
terminates the worker), and no state survives between calls.
68
69
Refusals are typed on both sides. The host refuses with a closed code set
70
(`manifest_invalid`, `digest_mismatch`, `capabilities_unsupported`,
71
`imports_declared`, `exports_missing`, `not_wasm`, `timeout`, `trap`,
72
`bad_packet`); the guest returns `{"refusal": {code, reason}}` as its
73
output packet. Both reach the model as text it can act on.
74
75
## What the skeleton (#26) should keep
76
77
- **The manifest as the whole declaration.** Digest pin checked before
78
  compile; import list checked before instantiate; capabilities are
79
  declared-and-enforced or refused, never ignored. The demo's
80
  `capabilities_unsupported` refusal for any non-empty mounts/hosts is the
81
  right default until host imports exist.
82
- **Refusals as values.** `LoadedPlugin | PluginRefusal` and
83
  `Uint8Array | PluginRefusal` compose; exceptions do not. The tool layer
84
  turning a refusal into one sentence is what let the live model handle the
85
  demo gracefully.
86
- **Termination as the timeout mechanism.** Whatever engine the skeleton
87
  adopts, the bound must survive a guest that never returns. Worker
88
  isolation (or engine epochs/fuel) is load-bearing; an in-process `await`
89
  with a timer is not enforcement.
90
- **The tool-materialization seam.** `pluginTool(loaded): CoderTool` is
91
  exactly the `CoderTool` shape the session already declares, so plugins,
92
  skills, shell, and delegate all ride one declaration path and the
93
  `/plugin load` wiring is ~20 lines in `cli.ts`. Tier-3 session loading
94
  per assessment 4.4 falls out of this for free.
95
- **Session-scoped registration with replace-by-name**, so iterating on a
96
  plugin re-declares rather than duplicates.
97
98
## What the skeleton should replace
99
100
- **The hand-rolled guest.** The demo guest parses JSON with a scanner
101
  because it ships dependency-free. The skeleton's owned Rust PDK should
102
  own the ABI: serde for packets, a `Refusal` enum, a `#[plugin_fn]`-style
103
  macro over `packet_alloc`/`handle_packet`, and the pack/unpack of the
104
  return word. Guest authors should never see a pointer.
105
- **The raw `WebAssembly` host, behind the engine abstraction.** The
106
  bare API has no fuel metering, no memory ceiling at instantiation (the
107
  manifest's `memory_max_mib` is declared but unenforced here), and no
108
  WASI. The issue's engine-abstraction constraint is right: keep the
109
  demo's *contract* (load → verify → instantiate → invoke-with-bound →
110
  bytes-or-refusal) as the interface and let wasmtime/Extism-derived code
111
  implement it. The worker-per-call model can stay as the Node fallback
112
  engine.
113
- **Per-call worker spawn, eventually.** ~10–30 ms per invocation is fine
114
  for a demo; a pooled worker holding a compiled `WebAssembly.Module`
115
  (modules are transferable) is the obvious next step if plugins get hot.
116
- **Schema validation.** The manifest carries typed input/output schemas
117
  and the demo forwards the input schema to the model but validates
118
  neither side. The skeleton should validate both (Effect Schema in the
119
  CLI per the assessment), and reject non-conforming output packets as a
120
  host-side `bad_packet`.
121
- **No receipt.** The contract wants a `tool.ran` thread event carrying
122
  the digest per invocation. The demo's tool run is visible in the
123
  transcript but writes no durable event; the skeleton must, since the
124
  economy lane projects over those records.
125
- **`/plugin` surface.** Only `load` exists. `list`, `unload`, the
126
  digest-pinned local catalog, and the `capability` discovery tool
127
  (tiers 1–2 of assessment 4.4) are all skeleton scope.
128
129
## Open questions
130
131
- **WASI.** The demo proves pure compute needs none. The pilot (foreign
132
  session resume) needs read-only mounts, which means WASI preview 1
133
  filesystem imports or Extism-style host functions — the first real host
134
  import surface, and the first ask-once approval per assessment 4.4.
135
- **Fuel/CPU metering.** Wall-clock termination bounds time but not spend;
136
  fuel or epoch interruption needs an engine (wasmtime) the plain Node API
137
  does not expose. Decide whether the Node host ever needs it or whether
138
  wall-clock is the Node engine's honest ceiling.
139
- **Memory ceiling.** Enforceable today by instantiating with a
140
  host-provided bounded `WebAssembly.Memory` only if the guest imports its
141
  memory; rustc's default is to export it. The PDK could flip guests to
142
  imported memory, or the engine abstraction owns the limit.
143
- **Effect integration.** The host is promise-based to match `CoderTool`.
144
  Whether the skeleton wraps it as an Effect service with typed errors
145
  (per the workspace's Effect conventions) or stays at the tool seam is a
146
  skeleton decision; nothing in the demo blocks either.
147
- **Packet encoding.** JSON-in-JSON (arguments → UTF-8 JSON packet) is
148
  legible and matches the manifest schemas, but binary payloads (the
149
  session-resume pilot moves transcripts) may want a length-prefixed or
150
  CBOR packet kind — the manifest's `abi.kind: "packet-v0"` field exists
151
  so this can version.
152
- **Where guest crates live.** `plugins/word-stats` is a standalone crate
153
  (`[workspace]` empty table) so the monorepo's cargo workspace does not
154
  build it. The skeleton should decide whether guests join the workspace,
155
  get a `plugins/` workspace of their own, or live out-of-repo entirely
156
  once the registry exists.
packages/openagents-cli/scripts/plugin-demo.mjs added +75

@@ -0,0 +1,75 @@

1
#!/usr/bin/env node
2
/**
3
 * The plugin walking demo, end to end, from the shell:
4
 * manifest -> digest verified -> pure-compute check -> invoke -> result,
5
 * then the refusal paths: a guest refusal, a tampered digest, and a timeout.
6
 *
7
 * Run from packages/openagents-cli after a build:
8
 *
9
 *     pnpm build && node scripts/plugin-demo.mjs
10
 *
11
 * To see it inside the chat instead, load it as a session tool:
12
 *
13
 *     printf '/plugin load ../../plugins/word-stats/manifest.json\n<prompt>\n' \
14
 *       | node dist/main.js coder --plain
15
 */
16
17
import { copyFileSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
18
import { tmpdir } from "node:os";
19
import { dirname, join, resolve } from "node:path";
20
import { fileURLToPath } from "node:url";
21
22
const here = dirname(fileURLToPath(import.meta.url));
23
const dist = resolve(here, "../dist/coder-plugins.js");
24
25
let host;
26
try {
27
  host = await import(dist);
28
} catch {
29
  console.error("Build first: pnpm build (this demo runs against dist/).");
30
  process.exit(1);
31
}
32
const { describeLoad, invokePlugin, isRefusal, loadPluginFromManifest, pluginTool } = host;
33
34
const manifestPath = resolve(here, "../../../plugins/word-stats/manifest.json");
35
const say = (label, text) => console.log(`\n== ${label}\n${text}`);
36
37
// 1. Load: manifest read, digest verified, imports proven empty.
38
const plugin = loadPluginFromManifest(manifestPath);
39
if (isRefusal(plugin)) {
40
  console.error(describeLoad(plugin));
41
  process.exit(1);
42
}
43
say("load", describeLoad(plugin));
44
45
// 2. Invoke through the tool the manifest materializes, as the model would.
46
const tool = pluginTool(plugin);
47
const text = "the quick brown fox jumps over the lazy dog";
48
say(
49
  `tool run: ${tool.name}({ text: "${text}" })`,
50
  await tool.run({ text }, new AbortController().signal),
51
);
52
53
// 3. The guest's own typed refusal, passed through as the output packet.
54
const bad = await invokePlugin(plugin, new TextEncoder().encode('{"wrong": true}'));
55
say("guest refusal", isRefusal(bad) ? describeLoad(bad) : new TextDecoder().decode(bad));
56
57
// 4. A tampered artifact does not load.
58
const dir = mkdtempSync(join(tmpdir(), "plugin-demo-"));
59
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
60
copyFileSync(
61
  resolve(dirname(manifestPath), manifest.artifact.path),
62
  join(dir, manifest.artifact.path),
63
);
64
manifest.artifact.digest = `sha256:${"0".repeat(64)}`;
65
writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
66
say("tampered digest", describeLoad(loadPluginFromManifest(join(dir, "manifest.json"))));
67
68
// 5. A guest that never returns is terminated at the bound.
69
const spin = new TextEncoder().encode(JSON.stringify({ text: "x", spin: true }));
70
const started = Date.now();
71
const timedOut = await invokePlugin(plugin, spin, { timeoutMs: 500 });
72
say(
73
  `runaway guest (${String(Date.now() - started)}ms)`,
74
  isRefusal(timedOut) ? `refused (${timedOut.code}): ${timedOut.reason}` : "unexpectedly answered",
75
);
packages/openagents-cli/src/cli.ts modified +50 -22

@@ -46,7 +46,15 @@ import {

46 46
} from "./coder-ollama.js";
47 47
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
48 48
import { delegateTool, openagentsTool, shellTool, skillTool } from "./coder-tools.js";
49
import {
50
  describeLoad,
51
  isRefusal,
52
  loadPluginFromManifest,
53
  pluginTool,
54
  type LoadedPlugin,
55
} from "./coder-plugins.js";
49 56
import { spawnSync } from "node:child_process";
57
import { resolve as resolvePath } from "node:path";
50 58
51 59
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";
52 60
import { loadSkillSelection, standingContext } from "./coder-skills.js";

@@ -1603,9 +1611,7 @@ async function buildDelegation(options: {

1603 1611
  // than guess.
1604 1612
  const laneFor = (choice: string) => {
1605 1613
    const harness = /^devin(:.+)?$/.test(choice)
1606
      ? new DevinHarness(
1607
          choice.startsWith("devin:") ? { permissionMode: choice.slice(6) } : {},
1608
        )
1614
      ? new DevinHarness(choice.startsWith("devin:") ? { permissionMode: choice.slice(6) } : {})
1609 1615
      : new OpencodeHarness({
1610 1616
          model: choice,
1611 1617
          ...(command === undefined ? {} : { command }),

@@ -1762,7 +1768,9 @@ const coderCommand = Command.make(

1762 1768
      // neither.
1763 1769
      const named = Option.getOrUndefined(model);
1764 1770
      const localModel =
1765
        named === undefined && !offline ? yield* Effect.promise(() => discoverOllamaModel()) : undefined;
1771
        named === undefined && !offline
1772
          ? yield* Effect.promise(() => discoverOllamaModel())
1773
          : undefined;
1766 1774
1767 1775
      const wantsOllama = named === undefined ? localModel !== undefined : isOllamaModelFlag(named);
1768 1776
      const askedFor =

@@ -1811,14 +1819,13 @@ const coderCommand = Command.make(

1811 1819
      // An Ollama session reads the credential too, though it opens no thread of
1812 1820
      // its own. The parent answers locally; children still spend a server
1813 1821
      // grant, so a local session delegates exactly as a thread session does.
1814
      const stored =
1815
        offline
1816
          ? Option.none()
1817
          : yield* findToken(endpoint.origin).pipe(
1818
              Effect.catchTag("OpenAgentsCli.CredentialPersistenceUnavailable", () =>
1819
                Effect.succeed(Option.none()),
1820
              ),
1821
            );
1822
      const stored = offline
1823
        ? Option.none()
1824
        : yield* findToken(endpoint.origin).pipe(
1825
            Effect.catchTag("OpenAgentsCli.CredentialPersistenceUnavailable", () =>
1826
              Effect.succeed(Option.none()),
1827
            ),
1828
          );
1822 1829
1823 1830
      const thread =
1824 1831
        Option.isSome(stored) && !wantsOllama

@@ -1850,16 +1857,15 @@ const coderCommand = Command.make(

1850 1857
      // Children get their own thread on their own model. The conversation
1851 1858
      // stays on the model it opened with, and a fan-out spends a budget the
1852 1859
      // reader's next question does not share.
1853
      const childThread =
1854
        Option.isSome(stored)
1855
          ? yield* Effect.promise(() =>
1856
              openChildThread({
1857
                origin: endpoint.origin,
1858
                token: Redacted.value(stored.value.token),
1859
                objective: `delegated children of openagents coder in ${workspace.repository}`,
1860
              }),
1861
            )
1862
          : undefined;
1860
      const childThread = Option.isSome(stored)
1861
        ? yield* Effect.promise(() =>
1862
            openChildThread({
1863
              origin: endpoint.origin,
1864
              token: Redacted.value(stored.value.token),
1865
              objective: `delegated children of openagents coder in ${workspace.repository}`,
1866
            }),
1867
          )
1868
        : undefined;
1863 1869
1864 1870
      const childGrant = childThread?.kind === "opened" ? childThread.thread.childGrant : undefined;
1865 1871

@@ -1907,6 +1913,11 @@ const coderCommand = Command.make(

1907 1913
      // Re-declared rather than declared once: switching a skill off in
1908 1914
      // `/skills` has to change what the next turn carries, and the tool
1909 1915
      // holding the catalog is the thing that changes.
1916
      // Session-scoped WASM plugins, loaded with `/plugin load <manifest>`.
1917
      // Experimental: the demo ahead of the plugin walking skeleton. A loaded
1918
      // plugin materializes one tool for the rest of this session and nothing
1919
      // outlives the process.
1920
      const plugins: LoadedPlugin[] = [];
1910 1921
      const declareTools = () => {
1911 1922
        const active = skills.active();
1912 1923
        const tools = [

@@ -1914,11 +1925,26 @@ const coderCommand = Command.make(

1914 1925
          ...(active.length === 0 ? [] : [skillTool(active)]),
1915 1926
          openagentsTool(),
1916 1927
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
1928
          ...plugins.map((plugin) => pluginTool(plugin)),
1917 1929
        ];
1918 1930
        source.useTools?.(tools);
1919 1931
      };
1920 1932
      declareTools();
1921 1933
1934
      const loadPlugin = (manifestPath: string): string => {
1935
        const outcome = loadPluginFromManifest(resolvePath(process.cwd(), manifestPath));
1936
        if (!isRefusal(outcome)) {
1937
          // Reloading a name replaces it: a demo iterates on one plugin, and
1938
          // two tools with one name would be a declaration the model cannot
1939
          // tell apart.
1940
          const at = plugins.findIndex((held) => held.manifest.name === outcome.manifest.name);
1941
          if (at >= 0) plugins.splice(at, 1);
1942
          plugins.push(outcome);
1943
          declareTools();
1944
        }
1945
        return describeLoad(outcome);
1946
      };
1947
1922 1948
      // Delegation is off rather than quietly running children on the
1923 1949
      // conversation's model, so the refusal that turned it off is what the
1924 1950
      // reader sees.

@@ -1961,12 +1987,14 @@ const coderCommand = Command.make(

1961 1987
                stdout: process.stdout,
1962 1988
                skills,
1963 1989
                onSkillsChanged: declareTools,
1990
                loadPlugin,
1964 1991
              })
1965 1992
            : await runCoderPlain(session, {
1966 1993
                stdin: process.stdin,
1967 1994
                stdout: process.stdout,
1968 1995
                prompt: oneShot,
1969 1996
                skills,
1997
                loadPlugin,
1970 1998
              });
1971 1999
        } finally {
1972 2000
          // An account holds eight open threads at once. A terminal that closed
packages/openagents-cli/src/coder-plain.ts modified +25 -1

@@ -29,6 +29,13 @@ export interface CoderPlainOptions {

29 29
  readonly prompt?: string | undefined;
30 30
  /** The workspace's skills, so `/skills` can report them. */
31 31
  readonly skills?: SkillSelection | undefined;
32
  /**
33
   * Load a WASM plugin from a manifest path and say what happened.
34
   *
35
   * Experimental, for `/plugin load <manifest>`. The caller owns the plugin
36
   * registry and the tool re-declaration.
37
   */
38
  readonly loadPlugin?: ((manifestPath: string) => string) | undefined;
32 39
}
33 40
34 41
export async function runCoderPlain(

@@ -100,13 +107,30 @@ export async function runCoderPlain(

100 107
          : `\n${all
101 108
              .map(
102 109
                (skill) =>
103
                  `${options.skills?.isOn(skill.name) ?? true ? "[on] " : "[off]"} ${skill.name}`,
110
                  `${(options.skills?.isOn(skill.name) ?? true) ? "[on] " : "[off]"} ${skill.name}`,
104 111
              )
105 112
              .join("\n")}\nRun the interactive session to switch one.\n`,
106 113
      );
107 114
      return;
108 115
    }
109 116
117
    // The same command as in the interface: it changes what the next turn
118
    // carries, so it is not sent to the model. Experimental.
119
    const pluginLoad = /^\/plugin\s+load\s+(.+)$/.exec(line.trim());
120
    if (pluginLoad !== null || /^\/plugin\b/.test(line.trim())) {
121
      const path = pluginLoad?.[1]?.trim();
122
      stdout.write(
123
        `\n${
124
          path === undefined || path.length === 0
125
            ? "Usage: /plugin load <path-to-manifest.json>. Experimental: loads a WASM plugin as a session tool."
126
            : options.loadPlugin === undefined
127
              ? "This session cannot load plugins."
128
              : options.loadPlugin(path)
129
        }\n`,
130
      );
131
      return;
132
    }
133
110 134
    written = 0;
111 135
    stdout.write(`\ncoder> `);
112 136
    await session.submit(line);
packages/openagents-cli/src/coder-plugins.ts added +408

@@ -0,0 +1,408 @@

1
/**
2
 * The demo WASM plugin host for `openagents coder`.
3
 *
4
 * This is the one-off walking demo ahead of the plugin walking skeleton
5
 * (OpenAgentsInc/openagents#26): load a manifest, verify the artifact digest,
6
 * instantiate a pure-compute WASM module, call `handle_packet(bytes) -> bytes`
7
 * under a timeout, and surface the whole thing to the model as a session
8
 * tool. `docs/plugins/2026-08-24-coder-plugin-demo-shape.md` records what the
9
 * real skeleton keeps and what it replaces.
10
 *
11
 * The contract, in miniature:
12
 *
13
 * - **Manifest first.** Identity, artifact digest, typed input and output
14
 *   schemas, and capability declarations. Absence of a capability means
15
 *   denial; this demo host accepts only the empty declaration — no mounts,
16
 *   no hosts — because it implements no host imports at all.
17
 * - **Digest before load.** The artifact's SHA-256 is compared to the
18
 *   manifest's pin before the module is compiled. A mismatch is a refusal,
19
 *   not a warning.
20
 * - **Pure compute only.** The module's import list must be empty. A module
21
 *   that asks for imports is refused by inspection, before instantiation, so
22
 *   the sandbox is a property of what was loaded rather than a hope about
23
 *   what it does.
24
 * - **Timeout by termination.** A WASM call is synchronous and cannot be
25
 *   preempted in-process, so every invocation runs in a `worker_threads`
26
 *   worker the host terminates when the manifest's bound expires. A runaway
27
 *   guest costs its own worker and nothing else.
28
 * - **Typed refusals both ways.** The host refuses with `{code, reason}`;
29
 *   the guest returns `{"refusal": {...}}` inside its output packet. Both
30
 *   read as text to the model, which can act on a refusal and cannot act on
31
 *   a turn that died.
32
 */
33
34
import { createHash } from "node:crypto";
35
import { readFileSync } from "node:fs";
36
import { dirname, resolve } from "node:path";
37
import { Worker } from "node:worker_threads";
38
39
import type { CoderTool } from "./coder-tools.js";
40
41
/** Why the host would not do what was asked. Never thrown; always returned. */
42
export interface PluginRefusal {
43
  readonly code:
44
    | "manifest_unreadable"
45
    | "manifest_invalid"
46
    | "artifact_unreadable"
47
    | "digest_mismatch"
48
    | "capabilities_unsupported"
49
    | "imports_declared"
50
    | "exports_missing"
51
    | "not_wasm"
52
    | "timeout"
53
    | "trap"
54
    | "bad_packet";
55
  readonly reason: string;
56
}
57
58
export const isRefusal = (value: unknown): value is PluginRefusal =>
59
  typeof value === "object" &&
60
  value !== null &&
61
  typeof (value as PluginRefusal).code === "string" &&
62
  typeof (value as PluginRefusal).reason === "string";
63
64
/** The manifest fields this host reads. The file may carry more. */
65
export interface PluginManifest {
66
  readonly name: string;
67
  readonly version: string;
68
  readonly description: string;
69
  readonly artifact: { readonly path: string; readonly digest: string };
70
  readonly abi: { readonly entry: string; readonly alloc: string };
71
  readonly interface: {
72
    readonly input: Record<string, unknown>;
73
    readonly output: Record<string, unknown>;
74
  };
75
  readonly capabilities: {
76
    readonly mounts: ReadonlyArray<unknown>;
77
    readonly hosts: ReadonlyArray<unknown>;
78
    readonly timeout_ms: number;
79
  };
80
}
81
82
/** A plugin that passed every check and is ready to invoke. */
83
export interface LoadedPlugin {
84
  readonly manifest: PluginManifest;
85
  /** The artifact bytes, held so an invocation cannot race a file rewrite. */
86
  readonly wasm: Uint8Array;
87
  /** The verified digest, `sha256:<hex>`, for receipts and notices. */
88
  readonly digest: string;
89
}
90
91
/** Ceiling on the manifest's own timeout, so a manifest cannot ask for an hour. */
92
const TIMEOUT_CEILING_MS = 30_000;
93
94
/** How much plugin output the model is shown. */
95
const PLUGIN_OUTPUT_LIMIT = 16_000;
96
97
const refuse = (code: PluginRefusal["code"], reason: string): PluginRefusal => ({ code, reason });
98
99
/**
100
 * Load a plugin from its manifest: parse, validate, verify the digest, and
101
 * prove by inspection that the module is pure compute with the declared ABI.
102
 *
103
 * Everything that can be checked before the first invocation is checked here,
104
 * so `/plugin load` either says exactly what is wrong or hands back a plugin
105
 * whose next failure can only be about the packet.
106
 */
107
export function loadPluginFromManifest(manifestPath: string): LoadedPlugin | PluginRefusal {
108
  let raw: string;
109
  try {
110
    raw = readFileSync(manifestPath, "utf8");
111
  } catch (cause) {
112
    return refuse("manifest_unreadable", cause instanceof Error ? cause.message : String(cause));
113
  }
114
115
  let parsed: unknown;
116
  try {
117
    parsed = JSON.parse(raw);
118
  } catch {
119
    return refuse("manifest_invalid", `${manifestPath} is not JSON`);
120
  }
121
122
  const manifest = validateManifest(parsed);
123
  if (isRefusal(manifest)) return manifest;
124
125
  // This host implements no imports, so the only capability set it can
126
  // enforce is the empty one. Declared-but-denied, never declared-and-ignored.
127
  if (manifest.capabilities.mounts.length > 0 || manifest.capabilities.hosts.length > 0) {
128
    return refuse(
129
      "capabilities_unsupported",
130
      "this host runs pure computation only: the manifest declares mounts or hosts, " +
131
        "and there are no host imports to grant them through",
132
    );
133
  }
134
135
  let wasm: Uint8Array<ArrayBuffer>;
136
  const artifactPath = resolve(dirname(manifestPath), manifest.artifact.path);
137
  try {
138
    // Copied out of the Buffer pool so the bytes sit on their own
139
    // ArrayBuffer, which both the compiler and the worker transfer want.
140
    wasm = Uint8Array.from(readFileSync(artifactPath));
141
  } catch (cause) {
142
    return refuse("artifact_unreadable", cause instanceof Error ? cause.message : String(cause));
143
  }
144
145
  const digest = `sha256:${createHash("sha256").update(wasm).digest("hex")}`;
146
  if (digest !== manifest.artifact.digest) {
147
    return refuse(
148
      "digest_mismatch",
149
      `the manifest pins ${manifest.artifact.digest} but ${manifest.artifact.path} is ${digest}; ` +
150
        "the artifact is not the one the manifest describes, so it does not load",
151
    );
152
  }
153
154
  let module: WebAssembly.Module;
155
  try {
156
    module = new WebAssembly.Module(wasm);
157
  } catch (cause) {
158
    return refuse("not_wasm", cause instanceof Error ? cause.message : String(cause));
159
  }
160
161
  const imports = WebAssembly.Module.imports(module);
162
  if (imports.length > 0) {
163
    const named = imports.map((entry) => `${entry.module}.${entry.name}`).join(", ");
164
    return refuse(
165
      "imports_declared",
166
      `the module asks for host imports (${named}); this host instantiates with none`,
167
    );
168
  }
169
170
  const exports = new Set(WebAssembly.Module.exports(module).map((entry) => entry.name));
171
  for (const name of [manifest.abi.entry, manifest.abi.alloc, "memory"]) {
172
    if (!exports.has(name)) {
173
      return refuse("exports_missing", `the module does not export \`${name}\``);
174
    }
175
  }
176
177
  return { manifest, wasm, digest };
178
}
179
180
function validateManifest(value: unknown): PluginManifest | PluginRefusal {
181
  const bad = (what: string): PluginRefusal =>
182
    refuse("manifest_invalid", `the manifest is missing or mistypes ${what}`);
183
184
  if (typeof value !== "object" || value === null) return bad("the top-level object");
185
  const record = value as Record<string, unknown>;
186
187
  const name = record["name"];
188
  if (typeof name !== "string" || !/^[a-z][a-z0-9_]{0,63}$/.test(name)) {
189
    return bad("`name` (lowercase identifier, it becomes the tool name)");
190
  }
191
  const version = record["version"];
192
  if (typeof version !== "string" || version.length === 0) return bad("`version`");
193
  const description = record["description"];
194
  if (typeof description !== "string" || description.length === 0) return bad("`description`");
195
196
  const artifact = record["artifact"] as Record<string, unknown> | undefined;
197
  if (
198
    typeof artifact !== "object" ||
199
    artifact === null ||
200
    typeof artifact["path"] !== "string" ||
201
    typeof artifact["digest"] !== "string" ||
202
    !artifact["digest"].startsWith("sha256:")
203
  ) {
204
    return bad("`artifact` (`path` and a `sha256:` `digest`)");
205
  }
206
207
  const abi = record["abi"] as Record<string, unknown> | undefined;
208
  if (
209
    typeof abi !== "object" ||
210
    abi === null ||
211
    typeof abi["entry"] !== "string" ||
212
    typeof abi["alloc"] !== "string"
213
  ) {
214
    return bad("`abi` (`entry` and `alloc` export names)");
215
  }
216
217
  const iface = record["interface"] as Record<string, unknown> | undefined;
218
  if (
219
    typeof iface !== "object" ||
220
    iface === null ||
221
    typeof iface["input"] !== "object" ||
222
    iface["input"] === null ||
223
    typeof iface["output"] !== "object" ||
224
    iface["output"] === null
225
  ) {
226
    return bad("`interface` (`input` and `output` JSON schemas)");
227
  }
228
229
  const capabilities = record["capabilities"] as Record<string, unknown> | undefined;
230
  if (
231
    typeof capabilities !== "object" ||
232
    capabilities === null ||
233
    !Array.isArray(capabilities["mounts"]) ||
234
    !Array.isArray(capabilities["hosts"]) ||
235
    typeof capabilities["timeout_ms"] !== "number" ||
236
    capabilities["timeout_ms"] <= 0
237
  ) {
238
    return bad("`capabilities` (`mounts`, `hosts`, positive `timeout_ms`)");
239
  }
240
241
  return {
242
    name,
243
    version,
244
    description,
245
    artifact: { path: artifact["path"], digest: artifact["digest"] },
246
    abi: { entry: abi["entry"], alloc: abi["alloc"] },
247
    interface: {
248
      input: iface["input"] as Record<string, unknown>,
249
      output: iface["output"] as Record<string, unknown>,
250
    },
251
    capabilities: {
252
      mounts: capabilities["mounts"],
253
      hosts: capabilities["hosts"],
254
      timeout_ms: Math.min(capabilities["timeout_ms"], TIMEOUT_CEILING_MS),
255
    },
256
  };
257
}
258
259
/**
260
 * The invocation worker, as source.
261
 *
262
 * A string rather than a file because the worker is part of this module's
263
 * contract, and a path into `dist/` breaks the moment tests run from source.
264
 * The worker instantiates the already-verified bytes with an empty import
265
 * object, copies the packet in through the guest's allocator, calls the entry,
266
 * and posts the output packet back. Anything the guest does wrong — a trap, an
267
 * out-of-range packet — comes back as a message, and anything it does forever
268
 * is ended by the host's timer terminating the whole worker.
269
 */
270
const INVOKE_WORKER = `
271
const { parentPort, workerData } = require("node:worker_threads");
272
(async () => {
273
  const { wasm, input, entry, alloc } = workerData;
274
  try {
275
    const { instance } = await WebAssembly.instantiate(wasm, {});
276
    const call = instance.exports[entry];
277
    const reserve = instance.exports[alloc];
278
    const memory = instance.exports.memory;
279
    const ptr = reserve(input.length);
280
    new Uint8Array(memory.buffer).set(input, ptr);
281
    const packed = call(ptr, input.length);
282
    const outPtr = Number(BigInt(packed) >> 32n);
283
    const outLen = Number(BigInt(packed) & 0xffffffffn);
284
    // Re-read the buffer: the call may have grown memory, detaching the old view.
285
    const view = new Uint8Array(memory.buffer);
286
    if (outPtr + outLen > view.length) {
287
      parentPort.postMessage({ trap: "the output packet points outside guest memory" });
288
      return;
289
    }
290
    parentPort.postMessage({ output: view.slice(outPtr, outPtr + outLen) });
291
  } catch (cause) {
292
    parentPort.postMessage({ trap: cause instanceof Error ? cause.message : String(cause) });
293
  }
294
})();
295
`;
296
297
/**
298
 * Call the plugin once: packet bytes in, packet bytes out, or a refusal.
299
 *
300
 * One worker per invocation. That costs a few milliseconds of instantiation
301
 * and buys the two properties the contract cares about: the timeout is
302
 * enforceable against a guest that never returns, and no state survives from
303
 * one call to the next, so every invocation runs on memory the previous one
304
 * cannot have corrupted.
305
 */
306
export function invokePlugin(
307
  plugin: LoadedPlugin,
308
  input: Uint8Array,
309
  options?: { readonly timeoutMs?: number | undefined },
310
): Promise<Uint8Array | PluginRefusal> {
311
  const timeoutMs = options?.timeoutMs ?? plugin.manifest.capabilities.timeout_ms;
312
313
  return new Promise((settle) => {
314
    const worker = new Worker(INVOKE_WORKER, {
315
      eval: true,
316
      workerData: {
317
        wasm: plugin.wasm,
318
        input,
319
        entry: plugin.manifest.abi.entry,
320
        alloc: plugin.manifest.abi.alloc,
321
      },
322
    });
323
324
    let done = false;
325
    const finish = (outcome: Uint8Array | PluginRefusal) => {
326
      if (done) return;
327
      done = true;
328
      clearTimeout(timer);
329
      void worker.terminate();
330
      settle(outcome);
331
    };
332
333
    const timer = setTimeout(() => {
334
      finish(
335
        refuse(
336
          "timeout",
337
          `the plugin did not answer within ${String(timeoutMs)}ms, the bound its manifest declares, ` +
338
            "and its worker was terminated",
339
        ),
340
      );
341
    }, timeoutMs);
342
343
    worker.on("message", (message: { output?: Uint8Array; trap?: string }) => {
344
      if (message.output !== undefined) finish(new Uint8Array(message.output));
345
      else finish(refuse("trap", message.trap ?? "the plugin trapped without a message"));
346
    });
347
    worker.on("error", (cause) => {
348
      finish(refuse("trap", cause.message));
349
    });
350
    worker.on("exit", (code) => {
351
      if (!done && code !== 0)
352
        finish(refuse("trap", `the plugin worker exited with code ${String(code)}`));
353
    });
354
  });
355
}
356
357
/**
358
 * The tool a loaded plugin materializes for the session.
359
 *
360
 * The manifest is the whole declaration: its name is the tool name, its
361
 * description is what the model reads, its input schema is the parameters.
362
 * `run` is the marshalling layer — arguments to a JSON packet, packet to the
363
 * plugin, output packet back as text — and every host refusal is a sentence
364
 * the model can act on rather than an exception the turn dies of.
365
 */
366
export function pluginTool(plugin: LoadedPlugin): CoderTool {
367
  const { manifest } = plugin;
368
  return {
369
    name: manifest.name,
370
    description:
371
      `${manifest.description}\n\n` +
372
      `Experimental WASM plugin \`${manifest.name}\` v${manifest.version}, loaded for this ` +
373
      `session only (${plugin.digest.slice(0, 19)}…). It runs sandboxed pure computation: no ` +
374
      "file, network, or environment access. The result is a JSON object with either `ok` or " +
375
      "`refusal`.",
376
    parameters: manifest.interface.input,
377
    run: async (args) => {
378
      const packet = new TextEncoder().encode(JSON.stringify(args));
379
      const outcome = await invokePlugin(plugin, packet);
380
      if (isRefusal(outcome)) {
381
        return `The plugin refused (${outcome.code}): ${outcome.reason}`;
382
      }
383
      let text: string;
384
      try {
385
        text = new TextDecoder("utf-8", { fatal: true }).decode(outcome);
386
      } catch {
387
        return `The plugin refused (bad_packet): the output packet is not UTF-8 (${String(outcome.length)} bytes)`;
388
      }
389
      return text.length <= PLUGIN_OUTPUT_LIMIT
390
        ? text
391
        : `${text.slice(0, PLUGIN_OUTPUT_LIMIT)}\n…[truncated]`;
392
    },
393
  };
394
}
395
396
/** What `/plugin load` reports, for a notice or a plain line. */
397
export function describeLoad(outcome: LoadedPlugin | PluginRefusal): string {
398
  if (isRefusal(outcome)) {
399
    return `Plugin not loaded (${outcome.code}): ${outcome.reason}`;
400
  }
401
  const { manifest } = outcome;
402
  return (
403
    `Loaded plugin \`${manifest.name}\` v${manifest.version} — digest verified ` +
404
    `(${outcome.digest.slice(0, 19)}…, ${String(outcome.wasm.length)} bytes, pure compute, ` +
405
    `${String(manifest.capabilities.timeout_ms)}ms bound). The \`${manifest.name}\` tool is ` +
406
    "declared to the model for this session. Experimental."
407
  );
408
}
packages/openagents-cli/src/coder-ui.ts modified +32 -12

@@ -112,6 +112,14 @@ export interface CoderUiOptions {

112 112
  readonly skills?: SkillSelection | undefined;
113 113
  /** Re-declare the tools after a skill is switched. */
114 114
  readonly onSkillsChanged?: (() => void) | undefined;
115
  /**
116
   * Load a WASM plugin from a manifest path and say what happened.
117
   *
118
   * Experimental, for `/plugin load <manifest>`. The caller owns the plugin
119
   * registry and the tool re-declaration; this interface only relays the path
120
   * and shows the sentence that comes back.
121
   */
122
  readonly loadPlugin?: ((manifestPath: string) => string) | undefined;
115 123
  readonly stdin: NodeJS.ReadStream;
116 124
  readonly stdout: NodeJS.WriteStream;
117 125
}

@@ -523,9 +531,11 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

523 531
        // row in hand: eight descriptions at once is the wall of text the
524 532
        // catalog exists to avoid.
525 533
        if (focused) {
526
          rows.push(...wrapStyled(skill.description, Math.max(20, width - 8), DIM).map(
527
            (line) => `        ${line}`,
528
          ));
534
          rows.push(
535
            ...wrapStyled(skill.description, Math.max(20, width - 8), DIM).map(
536
              (line) => `        ${line}`,
537
            ),
538
          );
529 539
        }
530 540
      }
531 541

@@ -545,11 +555,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

545 555
        rows.push(
546 556
          `${DIM}${"─".repeat(Math.max(0, width))}${RESET}`,
547 557
          hints(
548
            [
549
              { text: "↑↓ move" },
550
              { text: "space toggles" },
551
              { text: "esc returns" },
552
            ],
558
            [{ text: "↑↓ move" }, { text: "space toggles" }, { text: "esc returns" }],
553 559
            "",
554 560
            width,
555 561
          ),

@@ -558,10 +564,7 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

558 564
        paint(rows, rows.length, 1);
559 565
        return;
560 566
      }
561
      const transcriptHeight = Math.max(
562
        1,
563
        height - STATUS_ROWS - COMPOSER_ROWS - SPACER_ROWS - 1,
564
      );
567
      const transcriptHeight = Math.max(1, height - STATUS_ROWS - COMPOSER_ROWS - SPACER_ROWS - 1);
565 568
566 569
      const fleet = fleetLines(snapshot, width);
567 570
      // The fleet takes its rows from the transcript, not from the chrome: the

@@ -768,6 +771,23 @@ export function runCoderUi(session: CoderSession, options: CoderUiOptions): Prom

768 771
        return;
769 772
      }
770 773
774
      // `/plugin load` changes what the next turn carries, like `/skills`, so
775
      // it is not something to say to the model. Experimental.
776
      const pluginLoad = /^\/plugin\s+load\s+(.+)$/.exec(prompt.trim());
777
      if (pluginLoad !== null || /^\/plugin\b/.test(prompt.trim())) {
778
        const path = pluginLoad?.[1]?.trim();
779
        session.notice(
780
          path === undefined || path.length === 0
781
            ? "Usage: /plugin load <path-to-manifest.json>. Experimental: loads a WASM plugin " +
782
                "as a session tool."
783
            : options.loadPlugin === undefined
784
              ? "This session cannot load plugins."
785
              : options.loadPlugin(path),
786
        );
787
        render();
788
        return;
789
      }
790
771 791
      if (prompt.trimStart().startsWith("/delegate")) {
772 792
        void session.submit(prompt);
773 793
        render();
packages/openagents-cli/test/coder-plugins.test.ts added +207

@@ -0,0 +1,207 @@

1
/**
2
 * The plugin demo, proved end to end against the checked-in artifact:
3
 * manifest read, digest verified, module inspected, packet in, packet out,
4
 * refusals typed, timeout enforced by termination, and the whole path
5
 * surfaced through a coder session as a tool call in the transcript.
6
 */
7
8
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
9
import { tmpdir } from "node:os";
10
import { fileURLToPath } from "node:url";
11
import { join } from "node:path";
12
import { PassThrough } from "node:stream";
13
14
import { describe, expect, it } from "vitest";
15
16
import {
17
  describeLoad,
18
  invokePlugin,
19
  isRefusal,
20
  loadPluginFromManifest,
21
  pluginTool,
22
  type LoadedPlugin,
23
} from "../src/coder-plugins.js";
24
import { CoderSession, type ReplyChunk, type ReplySource } from "../src/coder-session.js";
25
import { runCoderPlain } from "../src/coder-plain.js";
26
import type { CoderTool } from "../src/coder-tools.js";
27
28
const MANIFEST = fileURLToPath(
29
  new URL("../../../plugins/word-stats/manifest.json", import.meta.url),
30
);
31
32
const loadFixture = (): LoadedPlugin => {
33
  const outcome = loadPluginFromManifest(MANIFEST);
34
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
35
  return outcome;
36
};
37
38
describe("loadPluginFromManifest", () => {
39
  it("loads the checked-in demo plugin and verifies its digest", () => {
40
    const plugin = loadFixture();
41
    expect(plugin.manifest.name).toBe("word_stats");
42
    expect(plugin.digest).toBe(plugin.manifest.artifact.digest);
43
    expect(describeLoad(plugin)).toContain("digest verified");
44
  });
45
46
  it("refuses an artifact whose digest does not match the manifest's pin", () => {
47
    const dir = mkdtempSync(join(tmpdir(), "plugin-digest-"));
48
    const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
49
      artifact: { digest: string; path: string };
50
    };
51
    manifest.artifact.digest = `sha256:${"0".repeat(64)}`;
52
    writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
53
    writeFileSync(
54
      join(dir, manifest.artifact.path),
55
      readFileSync(
56
        fileURLToPath(new URL("../../../plugins/word-stats/word_stats.wasm", import.meta.url)),
57
      ),
58
    );
59
60
    const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
61
    expect(isRefusal(outcome) && outcome.code).toBe("digest_mismatch");
62
  });
63
64
  it("refuses a manifest that declares mounts or hosts", () => {
65
    const dir = mkdtempSync(join(tmpdir(), "plugin-caps-"));
66
    const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
67
      capabilities: { hosts: unknown[] };
68
    };
69
    manifest.capabilities.hosts = ["api.example.com"];
70
    writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
71
72
    const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
73
    expect(isRefusal(outcome) && outcome.code).toBe("capabilities_unsupported");
74
  });
75
});
76
77
describe("invokePlugin", () => {
78
  it("answers a packet with the statistics the guest computed", async () => {
79
    const plugin = loadFixture();
80
    const packet = new TextEncoder().encode(
81
      JSON.stringify({ text: "the quick brown fox jumps over the lazy dog" }),
82
    );
83
    const outcome = await invokePlugin(plugin, packet);
84
    if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
85
86
    const parsed = JSON.parse(new TextDecoder().decode(outcome)) as {
87
      ok: { words: number; top_word: { word: string; count: number } };
88
    };
89
    expect(parsed.ok.words).toBe(9);
90
    expect(parsed.ok.top_word).toEqual({ word: "the", count: 2 });
91
  });
92
93
  it("passes the guest's own typed refusal through as the output packet", async () => {
94
    const plugin = loadFixture();
95
    const outcome = await invokePlugin(plugin, new TextEncoder().encode('{"nope":1}'));
96
    if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
97
98
    const parsed = JSON.parse(new TextDecoder().decode(outcome)) as {
99
      refusal: { code: string };
100
    };
101
    expect(parsed.refusal.code).toBe("bad_packet");
102
  });
103
104
  it("terminates a guest that never returns, at the declared bound", async () => {
105
    const plugin = loadFixture();
106
    const packet = new TextEncoder().encode(JSON.stringify({ text: "x", spin: true }));
107
    const started = Date.now();
108
    const outcome = await invokePlugin(plugin, packet, { timeoutMs: 400 });
109
    expect(isRefusal(outcome) && outcome.code).toBe("timeout");
110
    // Generous ceiling: the point is that it came back at the bound, not at
111
    // the heat death of the worker.
112
    expect(Date.now() - started).toBeLessThan(5_000);
113
  });
114
});
115
116
describe("pluginTool", () => {
117
  it("materializes the manifest as a CoderTool the model can call", async () => {
118
    const tool = pluginTool(loadFixture());
119
    expect(tool.name).toBe("word_stats");
120
    expect(tool.parameters).toMatchObject({ type: "object", required: ["text"] });
121
122
    const answer = await tool.run({ text: "one two two" }, new AbortController().signal);
123
    expect(JSON.parse(answer)).toMatchObject({ ok: { words: 3 } });
124
  });
125
126
  it("reports a host refusal as a sentence rather than throwing", async () => {
127
    const plugin = loadFixture();
128
    const short: LoadedPlugin = {
129
      ...plugin,
130
      manifest: {
131
        ...plugin.manifest,
132
        capabilities: { ...plugin.manifest.capabilities, timeout_ms: 200 },
133
      },
134
    };
135
    const tool = pluginTool(short);
136
    const answer = await tool.run({ text: "x", spin: true }, new AbortController().signal);
137
    expect(answer).toContain("The plugin refused (timeout)");
138
  });
139
});
140
141
/**
142
 * A source that behaves the way the thread source does with tools: it takes
143
 * the declaration, and its one reply calls the plugin tool and reports the
144
 * call as chunks. This is the transcript proof: the tool a `/plugin load`
145
 * registered is reachable through the session loop and renders in `--plain`.
146
 */
147
class PluginCallingSource implements ReplySource {
148
  readonly model = "scripted";
149
  private tools: ReadonlyArray<CoderTool> = [];
150
151
  useTools(tools: ReadonlyArray<CoderTool>): void {
152
    this.tools = tools;
153
  }
154
155
  async *reply(prompt: string, signal: AbortSignal): AsyncIterable<ReplyChunk> {
156
    const tool = this.tools.find((candidate) => candidate.name === "word_stats");
157
    if (tool === undefined) {
158
      yield { type: "text", value: "No word_stats tool is declared." };
159
      return;
160
    }
161
    const args = { text: prompt };
162
    yield { type: "tool_call", callId: "call-1", name: tool.name, arguments: JSON.stringify(args) };
163
    const output = await tool.run(args, signal);
164
    yield { type: "tool_result", callId: "call-1", output, error: undefined };
165
    yield { type: "text", value: `The plugin answered: ${output}` };
166
  }
167
}
168
169
describe("the transcript", () => {
170
  it("shows /plugin load registering the tool and a turn calling it", async () => {
171
    const source = new PluginCallingSource();
172
    const session = new CoderSession(source, "openagents", "coder-plugin-demo");
173
174
    // The same wiring `openagents coder` does: the loader owns the registry
175
    // and re-declares the tools when a plugin lands.
176
    const plugins: LoadedPlugin[] = [];
177
    const declare = () => {
178
      source.useTools(plugins.map((plugin) => pluginTool(plugin)));
179
    };
180
    declare();
181
    const loadPlugin = (path: string): string => {
182
      const outcome = loadPluginFromManifest(path);
183
      if (isRefusal(outcome)) return describeLoad(outcome);
184
      plugins.push(outcome);
185
      declare();
186
      return describeLoad(outcome);
187
    };
188
189
    const stdin = new PassThrough();
190
    const stdout = new PassThrough();
191
    let transcript = "";
192
    stdout.on("data", (chunk: Buffer) => {
193
      transcript += chunk.toString("utf8");
194
    });
195
196
    const done = runCoderPlain(session, { stdin, stdout, skills: undefined, loadPlugin });
197
    stdin.write(`/plugin load ${MANIFEST}\n`);
198
    stdin.write("the quick brown fox\n");
199
    stdin.end();
200
    expect(await done).toBe(0);
201
202
    expect(transcript).toContain("Loaded plugin `word_stats`");
203
    expect(transcript).toContain("digest verified");
204
    expect(transcript).toContain("[tool] word_stats");
205
    expect(transcript).toContain('"words":4');
206
  });
207
});
packages/openagents-cli/test/coder-ui.test.ts modified +1 -2

@@ -394,6 +394,7 @@ describe("runCoderUi", () => {

394 394
describe("the /skills screen", () => {
395 395
  const skill = (name: string, description: string) => ({
396 396
    name,
397
    auto: false,
397 398
    description,
398 399
    body: "Body.",
399 400
    path: `/tmp/${name}/SKILL.md`,

@@ -524,7 +525,6 @@ describe("the /skills screen", () => {

524 525
    await screenUnderTest.running;
525 526
  });
526 527
527
528 528
  it("returns on a lone escape, the way a terminal sends one", async () => {
529 529
    const screenUnderTest = await open(selection());
530 530
    expect(screenUnderTest.rows().join("\n")).toContain("Skills");

@@ -656,7 +656,6 @@ describe("typing while a turn is running", () => {

656 656
    await running;
657 657
  });
658 658
659
660 659
  it("steers on enter and queues on shift+enter", async () => {
661 660
    const stdin = new FakeIn();
662 661
    const stdout = new FakeOut();
plugins/word-stats/.gitignore added +1

@@ -0,0 +1,1 @@

1
/target
plugins/word-stats/Cargo.lock added +7

@@ -0,0 +1,7 @@

1
# This file is automatically @generated by Cargo.
2
# It is not intended for manual editing.
3
version = 4
4
5
[[package]]
6
name = "word-stats"
7
version = "0.1.0"
plugins/word-stats/Cargo.toml added +24

@@ -0,0 +1,24 @@

1
[package]
2
name = "word-stats"
3
version = "0.1.0"
4
edition = "2021"
5
description = "Demo guest plugin for the OpenAgents coder plugin walking skeleton: text statistics, bytes in, bytes out."
6
license = "Apache-2.0"
7
8
# Standalone on purpose: the demo artifact must not join the monorepo's cargo
9
# workspace build. The real skeleton decides where guest crates live.
10
[workspace]
11
12
[lib]
13
crate-type = ["cdylib"]
14
15
# No dependencies on purpose: the artifact builds offline with a stock
16
# `rustup target add wasm32-unknown-unknown` toolchain, and the checked-in
17
# .wasm can be reproduced without touching a registry.
18
19
[profile.release]
20
opt-level = "z"
21
lto = true
22
panic = "abort"
23
codegen-units = 1
24
strip = true
plugins/word-stats/README.md added +26

@@ -0,0 +1,26 @@

1
# word-stats
2
3
The demo guest plugin for the coder plugin walking demo. Pure computation:
4
text statistics in, JSON out, no imports. The built artifact
5
`word_stats.wasm` and its digest pin in `manifest.json` are checked in so
6
the demo runs without a Rust toolchain.
7
8
Load it into a coder session:
9
10
```sh
11
# from packages/openagents-cli, after pnpm build
12
printf '/plugin load ../../plugins/word-stats/manifest.json\ncount the words in: hello hello world\n' \
13
  | node dist/main.js coder --plain
14
```
15
16
Rebuild the artifact (then update `artifact.digest` in `manifest.json`):
17
18
```sh
19
cargo build --release --target wasm32-unknown-unknown
20
cp target/wasm32-unknown-unknown/release/word_stats.wasm word_stats.wasm
21
shasum -a 256 word_stats.wasm
22
```
23
24
Built with rustc 1.94.1, no dependencies, so the build needs no network.
25
The ABI and what the real skeleton replaces are recorded in
26
`docs/plugins/2026-08-24-coder-plugin-demo-shape.md`.
plugins/word-stats/manifest.json added +67

@@ -0,0 +1,67 @@

1
{
2
  "manifest_version": 1,
3
  "name": "word_stats",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Compute statistics for a piece of text: byte, character, word, and line counts, the longest word, and the most frequent word. Pure computation with no file, network, or environment access. Use it when asked to measure or summarize the size or composition of text.",
7
  "artifact": {
8
    "path": "word_stats.wasm",
9
    "digest": "sha256:7c724f993da2d9de7c1256b020e2c80e6b998cf3c484d123efeebbab8afc9947"
10
  },
11
  "abi": {
12
    "kind": "packet-v0",
13
    "entry": "handle_packet",
14
    "alloc": "packet_alloc"
15
  },
16
  "interface": {
17
    "input": {
18
      "type": "object",
19
      "properties": {
20
        "text": {
21
          "type": "string",
22
          "description": "The text to analyze."
23
        }
24
      },
25
      "required": ["text"],
26
      "additionalProperties": false
27
    },
28
    "output": {
29
      "type": "object",
30
      "properties": {
31
        "ok": {
32
          "type": "object",
33
          "properties": {
34
            "bytes": { "type": "integer" },
35
            "chars": { "type": "integer" },
36
            "words": { "type": "integer" },
37
            "lines": { "type": "integer" },
38
            "longest_word": { "type": "string" },
39
            "top_word": {
40
              "type": "object",
41
              "properties": {
42
                "word": { "type": "string" },
43
                "count": { "type": "integer" }
44
              }
45
            }
46
          }
47
        },
48
        "refusal": {
49
          "type": "object",
50
          "properties": {
51
            "code": { "type": "string" },
52
            "reason": { "type": "string" }
53
          },
54
          "required": ["code", "reason"]
55
        }
56
      }
57
    }
58
  },
59
  "capabilities": {
60
    "mounts": [],
61
    "hosts": [],
62
    "timeout_ms": 2000,
63
    "memory_max_mib": 64
64
  },
65
  "price_msats": null,
66
  "license": "Apache-2.0"
67
}
plugins/word-stats/src/lib.rs added +184

@@ -0,0 +1,184 @@

1
//! Demo guest plugin for the OpenAgents coder plugin walking skeleton.
2
//!
3
//! The contract is `handle_packet(bytes) -> bytes`: the packet in is the
4
//! UTF-8 JSON encoding of the tool arguments, the packet out is UTF-8 JSON
5
//! that is either `{"ok": ...}` or `{"refusal": {"code": ..., "reason": ...}}`.
6
//! The plugin imports nothing — it is pure computation — so the host can
7
//! instantiate it with an empty import object and refuse any module that
8
//! asks for more.
9
//!
10
//! Memory crosses the boundary through two exports:
11
//!
12
//! - `packet_alloc(len) -> ptr` — the host asks the guest for a buffer and
13
//!   writes the input packet into guest linear memory.
14
//! - `handle_packet(ptr, len) -> u64` — returns the output packet's location
15
//!   packed as `(ptr << 32) | len`. The output buffer is leaked on purpose:
16
//!   the host reads it immediately and the instance is dropped after one
17
//!   call, so a free export would be ceremony for this demo. The real
18
//!   skeleton's PDK owns this convention.
19
//!
20
//! Input schema (mirrored in ../manifest.json):
21
//!   { "text": string, "spin"?: bool }
22
//! `spin: true` loops forever, existing solely so the host's timeout bound is
23
//! demonstrable against a real runaway guest.
24
//!
25
//! No dependencies, so the JSON handling is a deliberately small hand-rolled
26
//! scanner rather than serde. The real skeleton replaces this with an owned
27
//! Rust PDK that carries serde and the typed refusal enum.
28
29
use std::alloc::{alloc, Layout};
30
31
#[no_mangle]
32
pub extern "C" fn packet_alloc(len: u32) -> *mut u8 {
33
    let layout = Layout::from_size_align(len.max(1) as usize, 1).expect("layout");
34
    unsafe { alloc(layout) }
35
}
36
37
#[no_mangle]
38
pub extern "C" fn handle_packet(ptr: *const u8, len: u32) -> u64 {
39
    let input = unsafe { std::slice::from_raw_parts(ptr, len as usize) };
40
    let out = respond(input).into_bytes();
41
    let out_len = out.len() as u32;
42
    let out_ptr = packet_alloc(out_len);
43
    unsafe { std::ptr::copy_nonoverlapping(out.as_ptr(), out_ptr, out_len as usize) };
44
    ((out_ptr as u64) << 32) | u64::from(out_len)
45
}
46
47
fn respond(input: &[u8]) -> String {
48
    let Ok(json) = std::str::from_utf8(input) else {
49
        return refusal("bad_packet", "the input packet is not UTF-8");
50
    };
51
    let Some(text) = extract_string_field(json, "text") else {
52
        return refusal("bad_packet", "the input packet has no string `text` field");
53
    };
54
    if has_true_field(json, "spin") {
55
        // A runaway guest, on request, so the host's timeout is testable.
56
        let mut n: u64 = 0;
57
        loop {
58
            n = std::hint::black_box(n.wrapping_add(1));
59
        }
60
    }
61
62
    let bytes = text.len();
63
    let chars = text.chars().count();
64
    let lines = if text.is_empty() { 0 } else { text.lines().count() };
65
    let words: Vec<&str> = text.split_whitespace().collect();
66
    let longest = words.iter().max_by_key(|word| word.len()).copied().unwrap_or("");
67
68
    let mut counts: Vec<(String, u32)> = Vec::new();
69
    for word in &words {
70
        let lowered: String = word
71
            .chars()
72
            .filter(|c| c.is_alphanumeric())
73
            .collect::<String>()
74
            .to_lowercase();
75
        if lowered.is_empty() {
76
            continue;
77
        }
78
        match counts.iter_mut().find(|(seen, _)| *seen == lowered) {
79
            Some((_, n)) => *n += 1,
80
            None => counts.push((lowered, 1)),
81
        }
82
    }
83
    let top = counts.iter().max_by_key(|(_, n)| *n);
84
85
    let mut out = String::from("{\"ok\":{");
86
    out.push_str(&format!(
87
        "\"bytes\":{bytes},\"chars\":{chars},\"words\":{},\"lines\":{lines},\"longest_word\":{}",
88
        words.len(),
89
        quote(longest)
90
    ));
91
    if let Some((word, count)) = top {
92
        out.push_str(&format!(",\"top_word\":{{\"word\":{},\"count\":{count}}}", quote(word)));
93
    }
94
    out.push_str("}}");
95
    out
96
}
97
98
fn refusal(code: &str, reason: &str) -> String {
99
    format!("{{\"refusal\":{{\"code\":{},\"reason\":{}}}}}", quote(code), quote(reason))
100
}
101
102
/// JSON-quote a string, escaping what must be escaped.
103
fn quote(text: &str) -> String {
104
    let mut out = String::with_capacity(text.len() + 2);
105
    out.push('"');
106
    for c in text.chars() {
107
        match c {
108
            '"' => out.push_str("\\\""),
109
            '\\' => out.push_str("\\\\"),
110
            '\n' => out.push_str("\\n"),
111
            '\r' => out.push_str("\\r"),
112
            '\t' => out.push_str("\\t"),
113
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
114
            c => out.push(c),
115
        }
116
    }
117
    out.push('"');
118
    out
119
}
120
121
/// Find `"key": "<string>"` at the top level of a JSON object and decode it.
122
///
123
/// A scanner, not a parser: enough for the flat argument objects this demo's
124
/// manifest declares, and honest about being replaced by serde in the PDK.
125
fn extract_string_field(json: &str, key: &str) -> Option<String> {
126
    let needle = format!("\"{key}\"");
127
    let mut search_from = 0;
128
    loop {
129
        let at = json[search_from..].find(&needle)? + search_from;
130
        let mut rest = json[at + needle.len()..].chars().peekable();
131
        // Skip whitespace, require a colon, skip whitespace, require a quote.
132
        while rest.peek().is_some_and(|c| c.is_whitespace()) {
133
            rest.next();
134
        }
135
        if rest.next() != Some(':') {
136
            search_from = at + needle.len();
137
            continue;
138
        }
139
        while rest.peek().is_some_and(|c| c.is_whitespace()) {
140
            rest.next();
141
        }
142
        if rest.next() != Some('"') {
143
            search_from = at + needle.len();
144
            continue;
145
        }
146
        // Decode the JSON string.
147
        let mut value = String::new();
148
        loop {
149
            match rest.next()? {
150
                '"' => return Some(value),
151
                '\\' => match rest.next()? {
152
                    '"' => value.push('"'),
153
                    '\\' => value.push('\\'),
154
                    '/' => value.push('/'),
155
                    'n' => value.push('\n'),
156
                    'r' => value.push('\r'),
157
                    't' => value.push('\t'),
158
                    'b' => value.push('\u{8}'),
159
                    'f' => value.push('\u{c}'),
160
                    'u' => {
161
                        let hex: String = (0..4).filter_map(|_| rest.next()).collect();
162
                        let code = u32::from_str_radix(&hex, 16).ok()?;
163
                        value.push(char::from_u32(code).unwrap_or('\u{fffd}'));
164
                    }
165
                    _ => return None,
166
                },
167
                c => value.push(c),
168
            }
169
        }
170
    }
171
}
172
173
/// True when `"key": true` appears in the JSON text.
174
fn has_true_field(json: &str, key: &str) -> bool {
175
    let needle = format!("\"{key}\"");
176
    let Some(at) = json.find(&needle) else {
177
        return false;
178
    };
179
    json[at + needle.len()..]
180
        .trim_start()
181
        .strip_prefix(':')
182
        .map(|rest| rest.trim_start().starts_with("true"))
183
        .unwrap_or(false)
184
}
plugins/word-stats/word_stats.wasm added

Binary file. Nothing to show as text.

This page updates live while a promote is in flight · changelog