Advance the plugin host from demo to walking skeleton

cff6b045a796 · AtlantisPleb · · parent 8502aaa7d9a9

Advance the plugin host from demo to walking skeleton

Four pieces of OpenAgentsInc/openagents#26, on the demo that landed as
16d3fa5826:

- plugins/pdk: the owned Rust PDK. It owns the whole packet-v0 guest ABI —
  packet_alloc, handle_packet, the {"ok"}/{"refusal"} envelope, the
  return-word packing — behind plugin_entry!(handler), so an author writes
  one fn(Input) -> Result<Output, Refusal> over serde types and never sees
  a pointer. Typed RefusalCode enum shared with the host's guest-visible
  codes. word-stats is ported onto it as proof: the hand-rolled JSON
  scanner and pointer plumbing are gone, the artifact is regenerated and
  its digest pin updated, and the plugins/ Cargo workspace (rustc 1.94.1,
  wasm32-unknown-unknown) replaces the standalone crate.

- coder-plugin-engine.ts: the engine seam. PluginEngine is inspect (compile
  and report imports/exports) plus invoke (one packet through one instance
  under limits, with cancellation); the demo's worker-per-call
  WebAssembly host is now the default Node engine behind it, so a
  wasmtime/WASI engine can slot in without touching the tool layer.

- Read-only mounts, the first host capability: a manifest may declare
  mounts [{path, readonly: true}], which grants the guest exactly one
  import, openagents.read_file, answered host-side with confinement —
  relative paths only, lexical containment after resolving ..,
  symlink refusal, realpath check against the realpath'd root, and a
  1 MiB per-file bound — as a status-prefixed packet the PDK decodes.
  Imports must be covered by declared capabilities or the load refuses
  (imports_undeclared). plugins/file-stats proves the path, with tests for
  every escape: .., absolute path, symlink, oversize, undeclared mount.

- abi versioning: the manifest's abi.kind is now validated; anything but
  packet-v0 refuses with abi_unsupported.

All 9 demo tests stay green through the seam against the PDK-built
artifact; 12 new tests cover mounts and abi; the full openagents-cli suite
passes (490) and cargo test covers the PDK (8).

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 packages/openagents-cli/src/coder-plugin-engine.ts
  • modified packages/openagents-cli/src/coder-plugins.ts
  • added packages/openagents-cli/test/coder-plugin-mounts.test.ts
  • added plugins/Cargo.lock
  • added plugins/Cargo.toml
  • added plugins/README.md
  • added plugins/file-stats/Cargo.toml
  • added plugins/file-stats/README.md
  • added plugins/file-stats/data/sample.txt
  • added plugins/file-stats/file_stats.wasm
  • added plugins/file-stats/manifest.json
  • added plugins/file-stats/src/lib.rs
  • added plugins/pdk/Cargo.toml
  • added plugins/pdk/src/lib.rs
  • deleted plugins/word-stats/Cargo.lock
  • modified plugins/word-stats/Cargo.toml
  • modified plugins/word-stats/README.md
  • modified plugins/word-stats/manifest.json
  • modified plugins/word-stats/src/lib.rs
  • modified plugins/word-stats/word_stats.wasm

Diff

20 files changed, +1487 -362

packages/openagents-cli/src/coder-plugin-engine.ts added +329

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

1
/**
2
 * The plugin engine seam.
3
 *
4
 * The tool layer in `coder-plugins.ts` owns the manifest, the digest pin,
5
 * and the marshalling; everything that actually touches a WASM runtime sits
6
 * behind {@link PluginEngine} so an engine with fuel metering, memory
7
 * ceilings, or WASI (wasmtime, an Extism-derived host) can replace the Node
8
 * default without the tool layer noticing. Two operations:
9
 *
10
 * - `inspect` — compile the artifact and report its import and export
11
 *   names, so the loader can prove by inspection that the module asks for
12
 *   exactly the capabilities its manifest declares, before anything runs.
13
 * - `invoke` — instantiate the verified bytes, feed one packet through the
14
 *   `packet-v0` entry under the declared limits, and settle with the output
15
 *   packet or a typed refusal. Never throws.
16
 *
17
 * The default engine keeps the demo's model: one `node:worker_threads`
18
 * worker per invocation, terminated at the timeout (a WASM call is
19
 * synchronous and cannot be preempted in-process) or on cancellation, so a
20
 * runaway guest costs its own worker and nothing else, and no state
21
 * survives between calls.
22
 *
23
 * Capability imports live host-side of this seam too. When the job carries
24
 * mounts, the worker exposes exactly one import — `openagents.read_file` —
25
 * and confines every path: relative to a declared root only, `..` resolved
26
 * and checked, symlinks refused, and a per-file size bound. The answer
27
 * crosses back as a status-prefixed packet the PDK decodes: `0x00` + bytes,
28
 * or `0x01` + a `{"code", "reason"}` refusal.
29
 */
30
31
import { Worker } from "node:worker_threads";
32
33
/** Why the host would not do what was asked. Never thrown; always returned. */
34
export interface PluginRefusal {
35
  readonly code:
36
    | "manifest_unreadable"
37
    | "manifest_invalid"
38
    | "abi_unsupported"
39
    | "artifact_unreadable"
40
    | "digest_mismatch"
41
    | "capabilities_unsupported"
42
    | "mount_invalid"
43
    | "imports_undeclared"
44
    | "exports_missing"
45
    | "not_wasm"
46
    | "timeout"
47
    | "cancelled"
48
    | "trap"
49
    | "bad_packet";
50
  readonly reason: string;
51
}
52
53
export const isRefusal = (value: unknown): value is PluginRefusal =>
54
  typeof value === "object" &&
55
  value !== null &&
56
  typeof (value as PluginRefusal).code === "string" &&
57
  typeof (value as PluginRefusal).reason === "string";
58
59
export const refuse = (code: PluginRefusal["code"], reason: string): PluginRefusal => ({
60
  code,
61
  reason,
62
});
63
64
/** What a compiled module declares, before anything is instantiated. */
65
export interface ModuleShape {
66
  /** Import names as `module.name`, e.g. `openagents.read_file`. */
67
  readonly imports: ReadonlyArray<string>;
68
  readonly exports: ReadonlyArray<string>;
69
}
70
71
/** One packet through one instance, under limits. */
72
export interface EngineJob {
73
  readonly wasm: Uint8Array;
74
  /** The `packet-v0` entry export name (`handle_packet`). */
75
  readonly entry: string;
76
  /** The allocator export name (`packet_alloc`). */
77
  readonly alloc: string;
78
  /** The input packet. */
79
  readonly input: Uint8Array;
80
  /** Wall-clock bound; at expiry the instance is destroyed, not awaited. */
81
  readonly timeoutMs: number;
82
  /**
83
   * Resolved, realpath'd absolute directory roots the guest may read
84
   * through `openagents.read_file`. Empty means the import does not exist.
85
   */
86
  readonly mounts: ReadonlyArray<string>;
87
  /** Per-file byte bound for mounted reads. */
88
  readonly mountFileLimit: number;
89
  /** Cancels the invocation the same way the timeout does. */
90
  readonly signal?: AbortSignal | undefined;
91
}
92
93
/**
94
 * A WASM engine the plugin host can run on. Implementations must enforce
95
 * the job's limits themselves — the tool layer never wraps an engine in a
96
 * timer, because an engine that cannot kill a runaway guest is not
97
 * enforcing anything.
98
 */
99
export interface PluginEngine {
100
  readonly name: string;
101
  inspect(wasm: Uint8Array): ModuleShape | PluginRefusal;
102
  invoke(job: EngineJob): Promise<Uint8Array | PluginRefusal>;
103
}
104
105
/**
106
 * The invocation worker, as source.
107
 *
108
 * A string rather than a file because the worker is part of this module's
109
 * contract, and a path into `dist/` breaks the moment tests run from
110
 * source. The worker instantiates the already-verified bytes, copies the
111
 * packet in through the guest's allocator, calls the entry, and posts the
112
 * output packet back. Anything the guest does wrong — a trap, an
113
 * out-of-range packet — comes back as a message, and anything it does
114
 * forever is ended by the host's timer terminating the whole worker.
115
 *
116
 * Mount confinement runs in here, synchronously, because a WASM import is
117
 * a synchronous call: lexical containment after resolving `..`, a symlink
118
 * refusal on the target, a realpath check against the realpath'd root so a
119
 * symlinked parent cannot smuggle the read out, and the size bound checked
120
 * before the bytes are touched.
121
 */
122
const INVOKE_WORKER = `
123
const { parentPort, workerData } = require("node:worker_threads");
124
const { lstatSync, readFileSync, realpathSync } = require("node:fs");
125
const { isAbsolute, resolve, sep } = require("node:path");
126
(async () => {
127
  const { wasm, input, entry, alloc, mounts, mountFileLimit } = workerData;
128
  try {
129
    let guest = null;
130
131
    const refusalPacket = (code, reason) => {
132
      const body = new TextEncoder().encode(JSON.stringify({ code, reason }));
133
      const packet = new Uint8Array(body.length + 1);
134
      packet[0] = 1;
135
      packet.set(body, 1);
136
      return packet;
137
    };
138
    const okPacket = (bytes) => {
139
      const packet = new Uint8Array(bytes.length + 1);
140
      packet[0] = 0;
141
      packet.set(bytes, 1);
142
      return packet;
143
    };
144
145
    const readMounted = (path) => {
146
      if (isAbsolute(path)) {
147
        return refusalPacket("mount_denied", "absolute paths are refused; mounted paths are relative to a declared mount root");
148
      }
149
      for (const root of mounts) {
150
        const candidate = resolve(root, path);
151
        // Lexical confinement: resolve() has already applied "..", so a
152
        // candidate outside the root is an escape, not a file in it.
153
        if (candidate !== root && !candidate.startsWith(root + sep)) {
154
          return refusalPacket("mount_denied", "the path escapes the mount root");
155
        }
156
        let stat;
157
        try {
158
          stat = lstatSync(candidate);
159
        } catch {
160
          continue; // Not in this mount; try the next declared root.
161
        }
162
        if (stat.isSymbolicLink()) {
163
          return refusalPacket("mount_denied", "symlinks inside a mount are refused");
164
        }
165
        if (!stat.isFile()) {
166
          return refusalPacket("file_unreadable", "the path is not a regular file");
167
        }
168
        // A symlinked parent directory can still point outside; the real
169
        // path of the candidate must sit under the real path of the root.
170
        let real;
171
        try {
172
          real = realpathSync(candidate);
173
        } catch (cause) {
174
          return refusalPacket("file_unreadable", String((cause && cause.message) || cause));
175
        }
176
        if (real !== root && !real.startsWith(root + sep)) {
177
          return refusalPacket("mount_denied", "the path resolves outside the mount root");
178
        }
179
        if (stat.size > mountFileLimit) {
180
          return refusalPacket("file_too_large", "the file is " + String(stat.size) + " bytes; the per-file bound is " + String(mountFileLimit));
181
        }
182
        try {
183
          return okPacket(readFileSync(candidate));
184
        } catch (cause) {
185
          return refusalPacket("file_unreadable", String((cause && cause.message) || cause));
186
        }
187
      }
188
      return refusalPacket("mount_denied", "no declared mount contains the path");
189
    };
190
191
    // Write an answer packet into guest memory through the guest's own
192
    // allocator and pack its location the way handle_packet does.
193
    const answerGuest = (packet) => {
194
      const ptr = guest[alloc](packet.length);
195
      new Uint8Array(guest.memory.buffer).set(packet, ptr);
196
      return (BigInt(ptr) << 32n) | BigInt(packet.length);
197
    };
198
199
    // The capability import exists only when the manifest declared mounts;
200
    // the loader has already refused any module that asks for more.
201
    const imports =
202
      mounts.length > 0
203
        ? {
204
            openagents: {
205
              read_file: (pathPtr, pathLen) => {
206
                const memory = new Uint8Array(guest.memory.buffer);
207
                const path = new TextDecoder().decode(memory.slice(pathPtr, pathPtr + pathLen));
208
                let packet;
209
                try {
210
                  packet = readMounted(path);
211
                } catch (cause) {
212
                  packet = refusalPacket("file_unreadable", String((cause && cause.message) || cause));
213
                }
214
                return answerGuest(packet);
215
              },
216
            },
217
          }
218
        : {};
219
220
    const { instance } = await WebAssembly.instantiate(wasm, imports);
221
    guest = instance.exports;
222
    const ptr = guest[alloc](input.length);
223
    new Uint8Array(guest.memory.buffer).set(input, ptr);
224
    const packed = guest[entry](ptr, input.length);
225
    const outPtr = Number(BigInt(packed) >> 32n);
226
    const outLen = Number(BigInt(packed) & 0xffffffffn);
227
    // Re-read the buffer: the call may have grown memory, detaching the old view.
228
    const view = new Uint8Array(guest.memory.buffer);
229
    if (outPtr + outLen > view.length) {
230
      parentPort.postMessage({ trap: "the output packet points outside guest memory" });
231
      return;
232
    }
233
    parentPort.postMessage({ output: view.slice(outPtr, outPtr + outLen) });
234
  } catch (cause) {
235
    parentPort.postMessage({ trap: cause instanceof Error ? cause.message : String(cause) });
236
  }
237
})();
238
`;
239
240
/**
241
 * The default engine: plain `WebAssembly` in a worker per invocation.
242
 *
243
 * One worker per call costs a few milliseconds of instantiation and buys
244
 * the two properties the contract cares about: the timeout is enforceable
245
 * against a guest that never returns, and no state survives from one call
246
 * to the next, so every invocation runs on memory the previous one cannot
247
 * have corrupted. The manifest's `memory_max_mib` is declared but not
248
 * enforced here — rustc exports memory rather than importing it, so a
249
 * bounded host memory cannot be injected; a ceiling-enforcing engine slots
250
 * in through this same interface.
251
 */
252
export const nodeWorkerEngine: PluginEngine = {
253
  name: "node-worker",
254
255
  inspect(wasm) {
256
    let module: WebAssembly.Module;
257
    try {
258
      module = new WebAssembly.Module(wasm as Uint8Array<ArrayBuffer>);
259
    } catch (cause) {
260
      return refuse("not_wasm", cause instanceof Error ? cause.message : String(cause));
261
    }
262
    return {
263
      imports: WebAssembly.Module.imports(module).map((entry) => `${entry.module}.${entry.name}`),
264
      exports: WebAssembly.Module.exports(module).map((entry) => entry.name),
265
    };
266
  },
267
268
  invoke(job) {
269
    return new Promise((settle) => {
270
      const worker = new Worker(INVOKE_WORKER, {
271
        eval: true,
272
        workerData: {
273
          wasm: job.wasm,
274
          input: job.input,
275
          entry: job.entry,
276
          alloc: job.alloc,
277
          mounts: [...job.mounts],
278
          mountFileLimit: job.mountFileLimit,
279
        },
280
      });
281
282
      let done = false;
283
      const finish = (outcome: Uint8Array | PluginRefusal) => {
284
        if (done) return;
285
        done = true;
286
        clearTimeout(timer);
287
        job.signal?.removeEventListener("abort", onAbort);
288
        void worker.terminate();
289
        settle(outcome);
290
      };
291
292
      const timer = setTimeout(() => {
293
        finish(
294
          refuse(
295
            "timeout",
296
            `the plugin did not answer within ${String(job.timeoutMs)}ms, the bound its manifest declares, ` +
297
              "and its worker was terminated",
298
          ),
299
        );
300
      }, job.timeoutMs);
301
302
      const onAbort = () => {
303
        finish(refuse("cancelled", "the invocation was cancelled and its worker terminated"));
304
      };
305
      if (job.signal !== undefined) {
306
        if (job.signal.aborted) {
307
          onAbort();
308
          return;
309
        }
310
        job.signal.addEventListener("abort", onAbort, { once: true });
311
      }
312
313
      worker.on("message", (message: { output?: Uint8Array; trap?: string }) => {
314
        if (message.output !== undefined) finish(new Uint8Array(message.output));
315
        else finish(refuse("trap", message.trap ?? "the plugin trapped without a message"));
316
      });
317
      worker.on("error", (cause) => {
318
        finish(refuse("trap", cause.message));
319
      });
320
      worker.on("exit", (code) => {
321
        if (!done && code !== 0)
322
          finish(refuse("trap", `the plugin worker exited with code ${String(code)}`));
323
      });
324
    });
325
  },
326
};
327
328
/** The engine the host uses unless an invocation names another. */
329
export const defaultEngine: PluginEngine = nodeWorkerEngine;
packages/openagents-cli/src/coder-plugins.ts modified +164 -172

@@ -1,65 +1,64 @@

1 1
/**
2
 * The demo WASM plugin host for `openagents coder`.
2
 * The WASM plugin host for `openagents coder`.
3 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.
4
 * The walking skeleton for OpenAgentsInc/openagents#26: load a manifest,
5
 * verify the artifact digest, prove by inspection that the module asks for
6
 * exactly the capabilities its manifest declares, and invoke
7
 * `handle_packet(bytes) -> bytes` through the engine seam under the
8
 * declared limits. `docs/plugins/2026-08-24-coder-plugin-demo-shape.md`
9
 * records the demo this grew from.
10 10
 *
11
 * The contract, in miniature:
11
 * The contract:
12 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.
13
 * - **Manifest first.** Identity, artifact digest pin, the `packet-v0` ABI
14
 *   declaration, typed input and output schemas, and capability
15
 *   declarations. Absence of a capability means denial.
17 16
 * - **Digest before load.** The artifact's SHA-256 is compared to the
18 17
 *   manifest's pin before the module is compiled. A mismatch is a refusal,
19 18
 *   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.
19
 * - **Imports must be declared.** A module's import list must be covered by
20
 *   the capabilities its manifest declares: nothing for pure compute, and
21
 *   exactly `openagents.read_file` when the manifest declares read-only
22
 *   mounts. Anything else is refused by inspection, before instantiation,
23
 *   so the sandbox is a property of what was loaded rather than a hope
24
 *   about what it does.
25
 * - **Mounts are read-only and confined.** A declared mount resolves to a
26
 *   real directory at load; at invocation the engine's `read_file` import
27
 *   canonicalizes every path, refuses absolute paths, `..` escapes, and
28
 *   symlinks, and bounds the bytes per file.
29
 * - **Limits are the engine's job.** Timeout by termination, cancellation
30
 *   the same way. See {@link PluginEngine} in `coder-plugin-engine.ts`.
28 31
 * - **Typed refusals both ways.** The host refuses with `{code, reason}`;
29 32
 *   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.
33
 *   read as text to the model, which can act on a refusal and cannot act
34
 *   on a turn that died.
32 35
 */
33 36
34 37
import { createHash } from "node:crypto";
35
import { readFileSync } from "node:fs";
38
import { readFileSync, realpathSync, statSync } from "node:fs";
36 39
import { dirname, resolve } from "node:path";
37
import { Worker } from "node:worker_threads";
38 40
41
import {
42
  defaultEngine,
43
  isRefusal,
44
  refuse,
45
  type PluginEngine,
46
  type PluginRefusal,
47
} from "./coder-plugin-engine.js";
39 48
import type { CoderTool } from "./coder-tools.js";
40 49
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
}
50
export { isRefusal, type PluginEngine, type PluginRefusal } from "./coder-plugin-engine.js";
51
52
/** The one packet ABI this host speaks. The manifest must declare it. */
53
export const SUPPORTED_ABI = "packet-v0";
57 54
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";
55
/** A read-only directory grant, as the manifest declares it. */
56
export interface PluginMount {
57
  /** Directory path, resolved relative to the manifest's directory. */
58
  readonly path: string;
59
  /** Only `true` is accepted; a writable mount is refused, not downgraded. */
60
  readonly readonly: true;
61
}
63 62
64 63
/** The manifest fields this host reads. The file may carry more. */
65 64
export interface PluginManifest {

@@ -67,13 +66,13 @@ export interface PluginManifest {

67 66
  readonly version: string;
68 67
  readonly description: string;
69 68
  readonly artifact: { readonly path: string; readonly digest: string };
70
  readonly abi: { readonly entry: string; readonly alloc: string };
69
  readonly abi: { readonly kind: string; readonly entry: string; readonly alloc: string };
71 70
  readonly interface: {
72 71
    readonly input: Record<string, unknown>;
73 72
    readonly output: Record<string, unknown>;
74 73
  };
75 74
  readonly capabilities: {
76
    readonly mounts: ReadonlyArray<unknown>;
75
    readonly mounts: ReadonlyArray<PluginMount>;
77 76
    readonly hosts: ReadonlyArray<unknown>;
78 77
    readonly timeout_ms: number;
79 78
  };

@@ -86,25 +85,32 @@ export interface LoadedPlugin {

86 85
  readonly wasm: Uint8Array;
87 86
  /** The verified digest, `sha256:<hex>`, for receipts and notices. */
88 87
  readonly digest: string;
88
  /** Declared mounts, resolved to realpath'd absolute directory roots. */
89
  readonly mounts: ReadonlyArray<string>;
89 90
}
90 91
91 92
/** Ceiling on the manifest's own timeout, so a manifest cannot ask for an hour. */
92 93
const TIMEOUT_CEILING_MS = 30_000;
93 94
95
/** Per-file byte bound for reads through a mount. */
96
export const MOUNT_FILE_LIMIT = 1_048_576;
97
94 98
/** How much plugin output the model is shown. */
95 99
const PLUGIN_OUTPUT_LIMIT = 16_000;
96 100
97
const refuse = (code: PluginRefusal["code"], reason: string): PluginRefusal => ({ code, reason });
98
99 101
/**
100 102
 * 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.
103
 * prove by inspection that the module's imports are covered by its declared
104
 * capabilities.
102 105
 *
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
 * Everything that can be checked before the first invocation is checked
107
 * here, so `/plugin load` either says exactly what is wrong or hands back a
108
 * plugin whose next failure can only be about the packet.
106 109
 */
107
export function loadPluginFromManifest(manifestPath: string): LoadedPlugin | PluginRefusal {
110
export function loadPluginFromManifest(
111
  manifestPath: string,
112
  engine: PluginEngine = defaultEngine,
113
): LoadedPlugin | PluginRefusal {
108 114
  let raw: string;
109 115
  try {
110 116
    raw = readFileSync(manifestPath, "utf8");

@@ -122,18 +128,33 @@ export function loadPluginFromManifest(manifestPath: string): LoadedPlugin | Plu

122 128
  const manifest = validateManifest(parsed);
123 129
  if (isRefusal(manifest)) return manifest;
124 130
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) {
131
  // The only host capability that exists is the read-only mount. Anything
132
  // else is declared-but-denied, never declared-and-ignored.
133
  if (manifest.capabilities.hosts.length > 0) {
128 134
    return refuse(
129 135
      "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",
136
      "the manifest declares network hosts, and this host has no network capability to grant",
132 137
    );
133 138
  }
134 139
140
  const manifestDir = dirname(manifestPath);
141
  const mounts: string[] = [];
142
  for (const mount of manifest.capabilities.mounts) {
143
    const declared = resolve(manifestDir, mount.path);
144
    let root: string;
145
    try {
146
      root = realpathSync(declared);
147
      if (!statSync(root).isDirectory()) {
148
        return refuse("mount_invalid", `mount \`${mount.path}\` is not a directory`);
149
      }
150
    } catch {
151
      return refuse("mount_invalid", `mount \`${mount.path}\` does not resolve to a readable directory`);
152
    }
153
    mounts.push(root);
154
  }
155
135 156
  let wasm: Uint8Array<ArrayBuffer>;
136
  const artifactPath = resolve(dirname(manifestPath), manifest.artifact.path);
157
  const artifactPath = resolve(manifestDir, manifest.artifact.path);
137 158
  try {
138 159
    // Copied out of the Buffer pool so the bytes sit on their own
139 160
    // ArrayBuffer, which both the compiler and the worker transfer want.

@@ -151,30 +172,32 @@ export function loadPluginFromManifest(manifestPath: string): LoadedPlugin | Plu

151 172
    );
152 173
  }
153 174
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(", ");
175
  const shape = engine.inspect(wasm);
176
  if (isRefusal(shape)) return shape;
177
178
  // Every import must be granted by a declared capability. Mounts grant
179
  // exactly one: the read_file capability import.
180
  const granted = new Set(mounts.length > 0 ? ["openagents.read_file"] : []);
181
  const undeclared = shape.imports.filter((name) => !granted.has(name));
182
  if (undeclared.length > 0) {
183
    const grantHint =
184
      mounts.length > 0
185
        ? "the declared mounts grant only `openagents.read_file`"
186
        : "the manifest declares no capabilities, so the module may import nothing";
164 187
    return refuse(
165
      "imports_declared",
166
      `the module asks for host imports (${named}); this host instantiates with none`,
188
      "imports_undeclared",
189
      `the module asks for host imports its manifest does not declare (${undeclared.join(", ")}); ${grantHint}`,
167 190
    );
168 191
  }
169 192
170
  const exports = new Set(WebAssembly.Module.exports(module).map((entry) => entry.name));
193
  const exports = new Set(shape.exports);
171 194
  for (const name of [manifest.abi.entry, manifest.abi.alloc, "memory"]) {
172 195
    if (!exports.has(name)) {
173 196
      return refuse("exports_missing", `the module does not export \`${name}\``);
174 197
    }
175 198
  }
176 199
177
  return { manifest, wasm, digest };
200
  return { manifest, wasm, digest, mounts };
178 201
}
179 202
180 203
function validateManifest(value: unknown): PluginManifest | PluginRefusal {

@@ -208,10 +231,17 @@ function validateManifest(value: unknown): PluginManifest | PluginRefusal {

208 231
  if (
209 232
    typeof abi !== "object" ||
210 233
    abi === null ||
234
    typeof abi["kind"] !== "string" ||
211 235
    typeof abi["entry"] !== "string" ||
212 236
    typeof abi["alloc"] !== "string"
213 237
  ) {
214
    return bad("`abi` (`entry` and `alloc` export names)");
238
    return bad("`abi` (`kind`, `entry`, and `alloc`)");
239
  }
240
  if (abi["kind"] !== SUPPORTED_ABI) {
241
    return refuse(
242
      "abi_unsupported",
243
      `the manifest declares abi \`${abi["kind"]}\` and this host speaks \`${SUPPORTED_ABI}\` only`,
244
    );
215 245
  }
216 246
217 247
  const iface = record["interface"] as Record<string, unknown> | undefined;

@@ -238,122 +268,81 @@ function validateManifest(value: unknown): PluginManifest | PluginRefusal {

238 268
    return bad("`capabilities` (`mounts`, `hosts`, positive `timeout_ms`)");
239 269
  }
240 270
271
  const mounts: PluginMount[] = [];
272
  for (const entry of capabilities["mounts"]) {
273
    const mount = entry as Record<string, unknown> | null;
274
    if (
275
      typeof mount !== "object" ||
276
      mount === null ||
277
      typeof mount["path"] !== "string" ||
278
      mount["path"].length === 0
279
    ) {
280
      return bad("`capabilities.mounts[]` (each mount needs a `path`)");
281
    }
282
    if (mount["readonly"] !== true) {
283
      // Writable mounts are a capability this host does not have. Refusing
284
      // here keeps "declared means enforced" honest.
285
      return refuse(
286
        "capabilities_unsupported",
287
        `mount \`${mount["path"]}\` is not marked \`"readonly": true\`; only read-only mounts exist`,
288
      );
289
    }
290
    mounts.push({ path: mount["path"], readonly: true });
291
  }
292
241 293
  return {
242 294
    name,
243 295
    version,
244 296
    description,
245 297
    artifact: { path: artifact["path"], digest: artifact["digest"] },
246
    abi: { entry: abi["entry"], alloc: abi["alloc"] },
298
    abi: { kind: abi["kind"], entry: abi["entry"], alloc: abi["alloc"] },
247 299
    interface: {
248 300
      input: iface["input"] as Record<string, unknown>,
249 301
      output: iface["output"] as Record<string, unknown>,
250 302
    },
251 303
    capabilities: {
252
      mounts: capabilities["mounts"],
304
      mounts,
253 305
      hosts: capabilities["hosts"],
254 306
      timeout_ms: Math.min(capabilities["timeout_ms"], TIMEOUT_CEILING_MS),
255 307
    },
256 308
  };
257 309
}
258 310
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 311
/**
298 312
 * 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.
313
 * The engine owns instantiation, the capability imports, and the limits.
305 314
 */
306 315
export function invokePlugin(
307 316
  plugin: LoadedPlugin,
308 317
  input: Uint8Array,
309
  options?: { readonly timeoutMs?: number | undefined },
318
  options?: {
319
    readonly timeoutMs?: number | undefined;
320
    readonly signal?: AbortSignal | undefined;
321
    readonly engine?: PluginEngine | undefined;
322
  },
310 323
): 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
    });
324
  const engine = options?.engine ?? defaultEngine;
325
  return engine.invoke({
326
    wasm: plugin.wasm,
327
    entry: plugin.manifest.abi.entry,
328
    alloc: plugin.manifest.abi.alloc,
329
    input,
330
    timeoutMs: options?.timeoutMs ?? plugin.manifest.capabilities.timeout_ms,
331
    mounts: plugin.mounts,
332
    mountFileLimit: MOUNT_FILE_LIMIT,
333
    signal: options?.signal,
354 334
  });
355 335
}
356 336
337
/** One sentence describing what the plugin can reach, for the model. */
338
const reachDescription = (plugin: LoadedPlugin): string =>
339
  plugin.mounts.length > 0
340
    ? `It runs sandboxed with read-only access to ${String(plugin.mounts.length)} mounted ` +
341
      "director" +
342
      (plugin.mounts.length === 1 ? "y" : "ies") +
343
      "; no writes, no network, no environment access."
344
    : "It runs sandboxed pure computation: no file, network, or environment access.";
345
357 346
/**
358 347
 * The tool a loaded plugin materializes for the session.
359 348
 *

@@ -363,20 +352,19 @@ export function invokePlugin(

363 352
 * plugin, output packet back as text — and every host refusal is a sentence
364 353
 * the model can act on rather than an exception the turn dies of.
365 354
 */
366
export function pluginTool(plugin: LoadedPlugin): CoderTool {
355
export function pluginTool(plugin: LoadedPlugin, engine?: PluginEngine): CoderTool {
367 356
  const { manifest } = plugin;
368 357
  return {
369 358
    name: manifest.name,
370 359
    description:
371 360
      `${manifest.description}\n\n` +
372 361
      `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`.",
362
      `session only (${plugin.digest.slice(0, 19)}…). ${reachDescription(plugin)} The result ` +
363
      "is a JSON object with either `ok` or `refusal`.",
376 364
    parameters: manifest.interface.input,
377
    run: async (args) => {
365
    run: async (args, signal) => {
378 366
      const packet = new TextEncoder().encode(JSON.stringify(args));
379
      const outcome = await invokePlugin(plugin, packet);
367
      const outcome = await invokePlugin(plugin, packet, { signal, engine });
380 368
      if (isRefusal(outcome)) {
381 369
        return `The plugin refused (${outcome.code}): ${outcome.reason}`;
382 370
      }

@@ -441,9 +429,13 @@ export function describeLoad(outcome: LoadedPlugin | PluginRefusal): string {

441 429
    return `Plugin not loaded (${outcome.code}): ${outcome.reason}`;
442 430
  }
443 431
  const { manifest } = outcome;
432
  const reach =
433
    outcome.mounts.length > 0
434
      ? `${String(outcome.mounts.length)} read-only mount${outcome.mounts.length === 1 ? "" : "s"}`
435
      : "pure compute";
444 436
  return (
445 437
    `Loaded plugin \`${manifest.name}\` v${manifest.version} — digest verified ` +
446
    `(${outcome.digest.slice(0, 19)}…, ${String(outcome.wasm.length)} bytes, pure compute, ` +
438
    `(${outcome.digest.slice(0, 19)}…, ${String(outcome.wasm.length)} bytes, ${reach}, ` +
447 439
    `${String(manifest.capabilities.timeout_ms)}ms bound). The \`${manifest.name}\` tool is ` +
448 440
    "declared to the model for this session. Experimental."
449 441
  );
packages/openagents-cli/test/coder-plugin-mounts.test.ts added +199

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

1
/**
2
 * Read-only mounts, proved against the checked-in `file_stats` plugin:
3
 * a declared mount grants exactly the `openagents.read_file` capability
4
 * import, confinement refuses every escape (`..`, absolute paths,
5
 * symlinks), the per-file size bound holds, an undeclared mount refuses at
6
 * load, and an unknown ABI never loads at all.
7
 */
8
9
import {
10
  copyFileSync,
11
  mkdirSync,
12
  mkdtempSync,
13
  readFileSync,
14
  symlinkSync,
15
  writeFileSync,
16
} from "node:fs";
17
import { tmpdir } from "node:os";
18
import { join } from "node:path";
19
import { fileURLToPath } from "node:url";
20
21
import { describe, expect, it } from "vitest";
22
23
import {
24
  MOUNT_FILE_LIMIT,
25
  invokePlugin,
26
  isRefusal,
27
  loadPluginFromManifest,
28
  type LoadedPlugin,
29
} from "../src/coder-plugins.js";
30
31
const FILE_STATS_MANIFEST = fileURLToPath(
32
  new URL("../../../plugins/file-stats/manifest.json", import.meta.url),
33
);
34
const FILE_STATS_WASM = fileURLToPath(
35
  new URL("../../../plugins/file-stats/file_stats.wasm", import.meta.url),
36
);
37
const WORD_STATS_MANIFEST = fileURLToPath(
38
  new URL("../../../plugins/word-stats/manifest.json", import.meta.url),
39
);
40
41
/**
42
 * Stage a private copy of the file_stats plugin in a temp directory with an
43
 * empty `data/` mount, so a test can shape the mount's contents (and the
44
 * manifest) without touching the checked-in fixture.
45
 */
46
const stage = (mutateManifest?: (manifest: Record<string, unknown>) => void): string => {
47
  const dir = mkdtempSync(join(tmpdir(), "plugin-mount-"));
48
  const manifest = JSON.parse(readFileSync(FILE_STATS_MANIFEST, "utf8")) as Record<
49
    string,
50
    unknown
51
  >;
52
  mutateManifest?.(manifest);
53
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
54
  copyFileSync(FILE_STATS_WASM, join(dir, "file_stats.wasm"));
55
  mkdirSync(join(dir, "data"));
56
  return dir;
57
};
58
59
const load = (dir: string): LoadedPlugin => {
60
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
61
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
62
  return outcome;
63
};
64
65
/** Invoke file_stats for one path and parse the output packet. */
66
const statPath = async (
67
  plugin: LoadedPlugin,
68
  path: string,
69
): Promise<Record<string, Record<string, unknown>>> => {
70
  const packet = new TextEncoder().encode(JSON.stringify({ path }));
71
  const outcome = await invokePlugin(plugin, packet);
72
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
73
  return JSON.parse(new TextDecoder().decode(outcome)) as Record<
74
    string,
75
    Record<string, unknown>
76
  >;
77
};
78
79
describe("read-only mounts", () => {
80
  it("loads the checked-in plugin and reads a file inside the mount", async () => {
81
    const outcome = loadPluginFromManifest(FILE_STATS_MANIFEST);
82
    if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
83
    expect(outcome.mounts).toHaveLength(1);
84
85
    const answer = await statPath(outcome, "sample.txt");
86
    expect(answer["ok"]).toMatchObject({ path: "sample.txt", utf8: true, lines: 3 });
87
    expect(answer["ok"]?.["bytes"]).toBeGreaterThan(0);
88
  });
89
90
  it("reads through a subdirectory of the mount, but never a directory itself", async () => {
91
    const dir = stage();
92
    mkdirSync(join(dir, "data", "nested"));
93
    writeFileSync(join(dir, "data", "nested", "inner.txt"), "one\ntwo\n");
94
    const plugin = load(dir);
95
96
    const ok = await statPath(plugin, "nested/inner.txt");
97
    expect(ok["ok"]).toMatchObject({ lines: 2 });
98
99
    const refused = await statPath(plugin, "nested");
100
    expect(refused["refusal"]?.["code"]).toBe("file_unreadable");
101
  });
102
103
  it("refuses a `..` path that escapes the mount root", async () => {
104
    const dir = stage();
105
    writeFileSync(join(dir, "secret.txt"), "outside the mount");
106
    const plugin = load(dir);
107
108
    const answer = await statPath(plugin, "../secret.txt");
109
    expect(answer["refusal"]?.["code"]).toBe("mount_denied");
110
    // The secret's existence must not leak through the wording.
111
    expect(String(answer["refusal"]?.["reason"])).not.toContain("secret");
112
  });
113
114
  it("refuses an absolute path outside the root", async () => {
115
    const plugin = load(stage());
116
    const answer = await statPath(plugin, "/etc/passwd");
117
    expect(answer["refusal"]?.["code"]).toBe("mount_denied");
118
  });
119
120
  it("refuses a symlink inside the mount, wherever it points", async () => {
121
    const dir = stage();
122
    writeFileSync(join(dir, "secret.txt"), "outside the mount");
123
    symlinkSync(join(dir, "secret.txt"), join(dir, "data", "sneaky.txt"));
124
    const plugin = load(dir);
125
126
    const answer = await statPath(plugin, "sneaky.txt");
127
    expect(answer["refusal"]?.["code"]).toBe("mount_denied");
128
  });
129
130
  it("bounds the bytes a single read may return", async () => {
131
    const dir = stage();
132
    writeFileSync(join(dir, "data", "big.bin"), Buffer.alloc(MOUNT_FILE_LIMIT + 1));
133
    const plugin = load(dir);
134
135
    const answer = await statPath(plugin, "big.bin");
136
    expect(answer["refusal"]?.["code"]).toBe("file_too_large");
137
  });
138
139
  it("answers a missing file with a refusal, not a trap", async () => {
140
    const plugin = load(stage());
141
    const answer = await statPath(plugin, "not-there.txt");
142
    expect(answer["refusal"]?.["code"]).toBe("mount_denied");
143
  });
144
145
  it("refuses at load a module whose imports its manifest does not declare", () => {
146
    // file_stats imports openagents.read_file; strip the mount declaration
147
    // and the import is no longer granted by anything.
148
    const dir = stage((manifest) => {
149
      (manifest["capabilities"] as Record<string, unknown>)["mounts"] = [];
150
    });
151
    const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
152
    expect(isRefusal(outcome) && outcome.code).toBe("imports_undeclared");
153
  });
154
155
  it("refuses a mount that is not declared read-only", () => {
156
    const dir = stage((manifest) => {
157
      (manifest["capabilities"] as Record<string, unknown>)["mounts"] = [
158
        { path: "data", readonly: false },
159
      ];
160
    });
161
    const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
162
    expect(isRefusal(outcome) && outcome.code).toBe("capabilities_unsupported");
163
  });
164
165
  it("refuses a mount that does not resolve to a directory", () => {
166
    const dir = stage((manifest) => {
167
      (manifest["capabilities"] as Record<string, unknown>)["mounts"] = [
168
        { path: "no-such-dir", readonly: true },
169
      ];
170
    });
171
    const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
172
    expect(isRefusal(outcome) && outcome.code).toBe("mount_invalid");
173
  });
174
});
175
176
describe("abi versioning", () => {
177
  it("refuses a manifest that declares an abi this host does not speak", () => {
178
    const dir = mkdtempSync(join(tmpdir(), "plugin-abi-"));
179
    const manifest = JSON.parse(readFileSync(WORD_STATS_MANIFEST, "utf8")) as {
180
      abi: { kind: string };
181
      artifact: { path: string };
182
    };
183
    manifest.abi.kind = "packet-v9";
184
    writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
185
    copyFileSync(
186
      fileURLToPath(new URL("../../../plugins/word-stats/word_stats.wasm", import.meta.url)),
187
      join(dir, manifest.artifact.path),
188
    );
189
190
    const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
191
    expect(isRefusal(outcome) && outcome.code).toBe("abi_unsupported");
192
    expect(isRefusal(outcome) && outcome.reason).toContain("packet-v0");
193
  });
194
195
  it("loads the declared packet-v0 abi", () => {
196
    const outcome = loadPluginFromManifest(WORD_STATS_MANIFEST);
197
    expect(isRefusal(outcome)).toBe(false);
198
  });
199
});
plugins/Cargo.lock added +123

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

1
# This file is automatically @generated by Cargo.
2
# It is not intended for manual editing.
3
version = 4
4
5
[[package]]
6
name = "file-stats"
7
version = "0.1.0"
8
dependencies = [
9
 "openagents-pdk",
10
 "serde",
11
]
12
13
[[package]]
14
name = "itoa"
15
version = "1.0.18"
16
source = "registry+https://github.com/rust-lang/crates.io-index"
17
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
18
19
[[package]]
20
name = "memchr"
21
version = "2.8.3"
22
source = "registry+https://github.com/rust-lang/crates.io-index"
23
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
24
25
[[package]]
26
name = "openagents-pdk"
27
version = "0.1.0"
28
dependencies = [
29
 "serde",
30
 "serde_json",
31
]
32
33
[[package]]
34
name = "proc-macro2"
35
version = "1.0.107"
36
source = "registry+https://github.com/rust-lang/crates.io-index"
37
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
38
dependencies = [
39
 "unicode-ident",
40
]
41
42
[[package]]
43
name = "quote"
44
version = "1.0.47"
45
source = "registry+https://github.com/rust-lang/crates.io-index"
46
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
47
dependencies = [
48
 "proc-macro2",
49
]
50
51
[[package]]
52
name = "serde"
53
version = "1.0.229"
54
source = "registry+https://github.com/rust-lang/crates.io-index"
55
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
56
dependencies = [
57
 "serde_core",
58
 "serde_derive",
59
]
60
61
[[package]]
62
name = "serde_core"
63
version = "1.0.229"
64
source = "registry+https://github.com/rust-lang/crates.io-index"
65
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
66
dependencies = [
67
 "serde_derive",
68
]
69
70
[[package]]
71
name = "serde_derive"
72
version = "1.0.229"
73
source = "registry+https://github.com/rust-lang/crates.io-index"
74
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
75
dependencies = [
76
 "proc-macro2",
77
 "quote",
78
 "syn",
79
]
80
81
[[package]]
82
name = "serde_json"
83
version = "1.0.151"
84
source = "registry+https://github.com/rust-lang/crates.io-index"
85
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
86
dependencies = [
87
 "itoa",
88
 "memchr",
89
 "serde",
90
 "serde_core",
91
 "zmij",
92
]
93
94
[[package]]
95
name = "syn"
96
version = "3.0.4"
97
source = "registry+https://github.com/rust-lang/crates.io-index"
98
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
99
dependencies = [
100
 "proc-macro2",
101
 "quote",
102
 "unicode-ident",
103
]
104
105
[[package]]
106
name = "unicode-ident"
107
version = "1.0.24"
108
source = "registry+https://github.com/rust-lang/crates.io-index"
109
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
110
111
[[package]]
112
name = "word-stats"
113
version = "0.2.0"
114
dependencies = [
115
 "openagents-pdk",
116
 "serde",
117
]
118
119
[[package]]
120
name = "zmij"
121
version = "1.0.23"
122
source = "registry+https://github.com/rust-lang/crates.io-index"
123
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
plugins/Cargo.toml added +33

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

1
# The guest-plugin workspace: the owned PDK and the plugins built on it.
2
#
3
# Deliberately separate from the repo-root Cloud workspace: guest crates
4
# compile to `wasm32-unknown-unknown` artifacts that are checked in beside
5
# their manifests, and the monorepo's native builds must not pull them in.
6
#
7
# Build every artifact from here:
8
#
9
#     cargo build --release --target wasm32-unknown-unknown
10
#
11
# Built with rustc 1.94.1. See each plugin's README for the copy-and-digest
12
# step that follows a rebuild.
13
14
[workspace]
15
resolver = "2"
16
members = ["pdk", "word-stats", "file-stats"]
17
18
[workspace.package]
19
edition = "2021"
20
license = "Apache-2.0"
21
22
[workspace.dependencies]
23
openagents-pdk = { path = "pdk" }
24
serde = { version = "1", features = ["derive"] }
25
serde_json = "1"
26
27
# Small artifacts: the .wasm files are checked in, so size is a review cost.
28
[profile.release]
29
opt-level = "z"
30
lto = true
31
panic = "abort"
32
codegen-units = 1
33
strip = true
plugins/README.md added +49

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

1
# Guest plugins
2
3
The Cargo workspace for `openagents coder` WASM plugins and the owned PDK
4
that authors them. The host lives in
5
`packages/openagents-cli/src/coder-plugins.ts` with the engine seam in
6
`coder-plugin-engine.ts`; the contract is issue
7
OpenAgentsInc/openagents#26 and
8
`docs/plugins/2026-08-24-coder-plugin-demo-shape.md`.
9
10
## Layout
11
12
- `pdk/` — `openagents-pdk`, the library every guest builds on. It owns the
13
  `packet-v0` ABI (`packet_alloc`, `handle_packet`, the JSON envelope, the
14
  return-word packing), the typed `Refusal` enum, and the host capability
15
  imports (`read_mounted_file`). A plugin author writes one
16
  `fn handle(input) -> Result<Output, Refusal>` over serde types and
17
  invokes `plugin_entry!(handle)`.
18
- `word-stats/` — pure computation: no imports, text statistics.
19
- `file-stats/` — the read-only-mount proof: imports exactly
20
  `openagents.read_file`, which the host exposes only because its manifest
21
  declares a mount.
22
23
Each plugin's built `.wasm` artifact and its `sha256:` digest pin are
24
checked in beside the source, so the CLI runs them without a Rust
25
toolchain.
26
27
## Rebuilding artifacts
28
29
From this directory:
30
31
```sh
32
cargo build --release --target wasm32-unknown-unknown
33
cp target/wasm32-unknown-unknown/release/word_stats.wasm word-stats/word_stats.wasm
34
cp target/wasm32-unknown-unknown/release/file_stats.wasm file-stats/file_stats.wasm
35
shasum -a 256 word-stats/word_stats.wasm file-stats/file_stats.wasm
36
```
37
38
Then update each manifest's `artifact.digest` — the host refuses a stale
39
pin — and rerun the plugin tests in `packages/openagents-cli`.
40
41
The checked-in artifacts were built with rustc 1.94.1 targeting
42
`wasm32-unknown-unknown` (`rustup target add wasm32-unknown-unknown`),
43
release profile as declared in `Cargo.toml` (`opt-level = "z"`, `lto`,
44
`panic = "abort"`, `strip`). Dependencies are `serde` and `serde_json`
45
only, pinned by the checked-in `Cargo.lock`; with a warm cargo cache the
46
build needs no network.
47
48
This workspace is deliberately separate from the repo-root Cloud Rust
49
workspace: guest crates are cross-compiled artifacts, not native services.
plugins/file-stats/Cargo.toml added +13

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

1
[package]
2
name = "file-stats"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin proving read-only mounts: statistics for one file read through the host's confined read_file capability import."
7
8
[lib]
9
crate-type = ["cdylib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
plugins/file-stats/README.md added +23

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

1
# file-stats
2
3
The read-only-mount proof plugin. Its manifest declares one mount
4
(`data/`, relative to the manifest, `readonly: true`), so the host exposes
5
exactly one capability import — `openagents.read_file` — and confines every
6
path the guest asks for: relative paths only, no `..` escape, no symlinks,
7
a per-file size bound. The guest measures one mounted file: byte count,
8
UTF-8 or not, line count.
9
10
The escape-attempt tests live in
11
`packages/openagents-cli/test/coder-plugin-mounts.test.ts`.
12
13
Load it into a coder session:
14
15
```sh
16
# from packages/openagents-cli, after pnpm build
17
printf '/plugin load ../../plugins/file-stats/manifest.json\nmeasure sample.txt with file_stats\n' \
18
  | node dist/main.js coder --plain
19
```
20
21
Rebuild from `plugins/` (then update `artifact.digest` in `manifest.json`):
22
see `../README.md`. Built with rustc 1.94.1 targeting
23
`wasm32-unknown-unknown`.
plugins/file-stats/data/sample.txt added +3

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

1
The forge remembers every push.
2
A plugin declares what it needs, and absence means denial.
3
This file exists so file_stats has something to measure.
plugins/file-stats/file_stats.wasm added

Binary file. Nothing to show as text.

plugins/file-stats/manifest.json added +59

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

1
{
2
  "manifest_version": 1,
3
  "name": "file_stats",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Compute statistics for one file inside the plugin's declared read-only mount: byte count, whether it is UTF-8 text, and its line count. It cannot write, list directories, or reach outside the mount. Use it when asked to measure a mounted file.",
7
  "artifact": {
8
    "path": "file_stats.wasm",
9
    "digest": "sha256:1f44edeadd163efd8a6ee73eb89725a94e93c1fbabb66791ce02bbc6bcb3be7d"
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
        "path": {
21
          "type": "string",
22
          "description": "Path of the file to measure, relative to the plugin's mount root."
23
        }
24
      },
25
      "required": ["path"],
26
      "additionalProperties": false
27
    },
28
    "output": {
29
      "type": "object",
30
      "properties": {
31
        "ok": {
32
          "type": "object",
33
          "properties": {
34
            "path": { "type": "string" },
35
            "bytes": { "type": "integer" },
36
            "utf8": { "type": "boolean" },
37
            "lines": { "type": "integer" }
38
          }
39
        },
40
        "refusal": {
41
          "type": "object",
42
          "properties": {
43
            "code": { "type": "string" },
44
            "reason": { "type": "string" }
45
          },
46
          "required": ["code", "reason"]
47
        }
48
      }
49
    }
50
  },
51
  "capabilities": {
52
    "mounts": [{ "path": "data", "readonly": true }],
53
    "hosts": [],
54
    "timeout_ms": 2000,
55
    "memory_max_mib": 64
56
  },
57
  "price_msats": null,
58
  "license": "Apache-2.0"
59
}
plugins/file-stats/src/lib.rs added +47

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

1
//! File statistics over a read-only mount, as a `packet-v0` guest plugin.
2
//!
3
//! This is the proof plugin for the host's first capability import: the
4
//! manifest declares one read-only mount, so the host exposes
5
//! `openagents.read_file` to this module and confines every path it asks
6
//! for — relative paths only, no `..` escape, no symlinks, a per-file size
7
//! bound. The plugin itself holds no authority: it hands the host a path
8
//! and gets back bytes or a typed refusal, which the PDK surfaces as a
9
//! plain `Result`.
10
11
use openagents_pdk::{plugin_entry, read_mounted_file, Refusal};
12
use serde::{Deserialize, Serialize};
13
14
#[derive(Deserialize)]
15
struct Input {
16
    /// Path relative to a declared mount root.
17
    path: String,
18
}
19
20
#[derive(Serialize)]
21
struct Output {
22
    path: String,
23
    bytes: usize,
24
    utf8: bool,
25
    /// Line count when the file is UTF-8 text; absent otherwise.
26
    #[serde(skip_serializing_if = "Option::is_none")]
27
    lines: Option<usize>,
28
}
29
30
fn handle(input: Input) -> Result<Output, Refusal> {
31
    let bytes = read_mounted_file(&input.path)?;
32
    let lines = std::str::from_utf8(&bytes).ok().map(|text| {
33
        if text.is_empty() {
34
            0
35
        } else {
36
            text.lines().count()
37
        }
38
    });
39
    Ok(Output {
40
        path: input.path,
41
        bytes: bytes.len(),
42
        utf8: lines.is_some(),
43
        lines,
44
    })
45
}
46
47
plugin_entry!(handle);
plugins/pdk/Cargo.toml added +10

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

1
[package]
2
name = "openagents-pdk"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "The owned Rust PDK for OpenAgents WASM plugins: the packet-v0 ABI, serde packets, typed refusals, and the host capability imports."
7
8
[dependencies]
9
serde = { workspace = true }
10
serde_json = { workspace = true }
plugins/pdk/src/lib.rs added +373

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

1
//! The owned Rust PDK for OpenAgents WASM plugins.
2
//!
3
//! A plugin author writes one function over serde types:
4
//!
5
//! ```ignore
6
//! use openagents_pdk::{plugin_entry, Refusal};
7
//! use serde::{Deserialize, Serialize};
8
//!
9
//! #[derive(Deserialize)]
10
//! struct Input { text: String }
11
//!
12
//! #[derive(Serialize)]
13
//! struct Output { chars: usize }
14
//!
15
//! fn handle(input: Input) -> Result<Output, Refusal> {
16
//!     Ok(Output { chars: input.text.chars().count() })
17
//! }
18
//!
19
//! plugin_entry!(handle);
20
//! ```
21
//!
22
//! and the [`plugin_entry!`] macro generates the whole `packet-v0` ABI:
23
//! the `packet_alloc` export the host allocates through, the
24
//! `handle_packet(ptr, len) -> u64` export, the serde decode of the input
25
//! packet, the `{"ok": ...}` / `{"refusal": ...}` envelope on the way out,
26
//! and the `(ptr << 32) | len` packing of the return word. Authors never
27
//! see a pointer.
28
//!
29
//! ## The packet-v0 contract, as this crate owns it
30
//!
31
//! - The input packet is the UTF-8 JSON encoding of the tool arguments.
32
//!   A packet that does not decode into the handler's input type is
33
//!   answered with a `bad_packet` refusal, not a trap.
34
//! - The output packet is UTF-8 JSON: `{"ok": <output>}` on success,
35
//!   `{"refusal": {"code": ..., "reason": ...}}` otherwise. Refusals are
36
//!   values on both sides of the boundary; the PDK never panics on bad
37
//!   input and the handler returns `Result`, never throws.
38
//! - The output buffer is deliberately leaked. The host reads it
39
//!   immediately and drops the instance after one call — one instance per
40
//!   invocation is the host's isolation model — so a free export would be
41
//!   ceremony.
42
//!
43
//! ## Host capabilities
44
//!
45
//! [`read_mounted_file`] is the first host import: available only when the
46
//! plugin's manifest declares read-only mounts, and answered by the host
47
//! with either the file bytes or a typed refusal (`mount_denied`,
48
//! `file_unreadable`, `file_too_large`). A plugin that never calls it
49
//! links no imports at all — the compiler strips the unused extern — so a
50
//! pure-compute plugin still passes the host's empty-import inspection.
51
52
use serde::de::DeserializeOwned;
53
use serde::Serialize;
54
55
// Re-exported so plugin crates need only `openagents-pdk` in [dependencies].
56
pub use serde;
57
pub use serde_json;
58
59
/// Why the plugin would not do what was asked. Returned, never thrown.
60
///
61
/// The code set mirrors the host's guest-visible refusal codes, so a
62
/// refusal born on either side of the boundary reads the same in the
63
/// output packet.
64
#[derive(Debug, Clone, PartialEq, Eq)]
65
pub struct Refusal {
66
    pub code: RefusalCode,
67
    pub reason: String,
68
}
69
70
/// The closed set of guest-side refusal codes for `packet-v0`.
71
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72
pub enum RefusalCode {
73
    /// The input packet does not decode into the handler's input type,
74
    /// or an output failed to encode.
75
    BadPacket,
76
    /// The plugin was asked for something it does not do.
77
    Unsupported,
78
    /// The host refused a mounted-file read: the path is outside every
79
    /// declared mount, absolute, or reaches through a symlink.
80
    MountDenied,
81
    /// The host could not read the mounted file (missing, a directory,
82
    /// or an I/O failure).
83
    FileUnreadable,
84
    /// The mounted file exceeds the host's per-file size bound.
85
    FileTooLarge,
86
    /// The plugin's own invariant broke. A bug, stated as a value.
87
    Internal,
88
}
89
90
impl RefusalCode {
91
    pub const fn as_str(self) -> &'static str {
92
        match self {
93
            RefusalCode::BadPacket => "bad_packet",
94
            RefusalCode::Unsupported => "unsupported",
95
            RefusalCode::MountDenied => "mount_denied",
96
            RefusalCode::FileUnreadable => "file_unreadable",
97
            RefusalCode::FileTooLarge => "file_too_large",
98
            RefusalCode::Internal => "internal",
99
        }
100
    }
101
102
    /// The code for a host-authored refusal packet. Unknown codes fold to
103
    /// [`RefusalCode::Internal`]; the caller keeps the raw text in the reason.
104
    fn parse(code: &str) -> Option<Self> {
105
        match code {
106
            "bad_packet" => Some(RefusalCode::BadPacket),
107
            "unsupported" => Some(RefusalCode::Unsupported),
108
            "mount_denied" => Some(RefusalCode::MountDenied),
109
            "file_unreadable" => Some(RefusalCode::FileUnreadable),
110
            "file_too_large" => Some(RefusalCode::FileTooLarge),
111
            "internal" => Some(RefusalCode::Internal),
112
            _ => None,
113
        }
114
    }
115
}
116
117
impl Refusal {
118
    pub fn new(code: RefusalCode, reason: impl Into<String>) -> Self {
119
        Refusal { code, reason: reason.into() }
120
    }
121
122
    pub fn bad_packet(reason: impl Into<String>) -> Self {
123
        Refusal::new(RefusalCode::BadPacket, reason)
124
    }
125
126
    pub fn unsupported(reason: impl Into<String>) -> Self {
127
        Refusal::new(RefusalCode::Unsupported, reason)
128
    }
129
130
    pub fn internal(reason: impl Into<String>) -> Self {
131
        Refusal::new(RefusalCode::Internal, reason)
132
    }
133
}
134
135
/// Encode `{"refusal": {"code": ..., "reason": ...}}` as an output packet.
136
pub fn refusal_packet(refusal: &Refusal) -> Vec<u8> {
137
    serde_json::to_vec(&serde_json::json!({
138
        "refusal": { "code": refusal.code.as_str(), "reason": refusal.reason }
139
    }))
140
    // The value is two strings; encoding cannot fail.
141
    .expect("a refusal always encodes")
142
}
143
144
/// Decode the input packet, run the handler, encode the output envelope.
145
///
146
/// This is the whole guest side of `packet-v0` minus the pointer plumbing,
147
/// and it is total: every path returns a packet.
148
pub fn run_handler<I, O, F>(input: &[u8], handler: F) -> Vec<u8>
149
where
150
    I: DeserializeOwned,
151
    O: Serialize,
152
    F: FnOnce(I) -> Result<O, Refusal>,
153
{
154
    let parsed: I = match serde_json::from_slice(input) {
155
        Ok(value) => value,
156
        Err(err) => {
157
            return refusal_packet(&Refusal::bad_packet(format!(
158
                "the input packet does not decode: {err}"
159
            )))
160
        }
161
    };
162
    match handler(parsed) {
163
        Ok(output) => match serde_json::to_vec(&output) {
164
            Ok(body) => {
165
                let mut packet = Vec::with_capacity(body.len() + 8);
166
                packet.extend_from_slice(b"{\"ok\":");
167
                packet.extend_from_slice(&body);
168
                packet.push(b'}');
169
                packet
170
            }
171
            Err(err) => refusal_packet(&Refusal::internal(format!(
172
                "the output does not encode: {err}"
173
            ))),
174
        },
175
        Err(refusal) => refusal_packet(&refusal),
176
    }
177
}
178
179
/// Read a file from one of the manifest's declared read-only mounts.
180
///
181
/// The path is relative to a mount root; the host confines it (no absolute
182
/// paths, no `..` escapes, no symlinks, a per-file size bound) and answers
183
/// with the bytes or a typed refusal. On a target other than
184
/// `wasm32-unknown-unknown` — the PDK's own unit tests, for example — the
185
/// import does not exist and this returns `unsupported`.
186
pub fn read_mounted_file(path: &str) -> Result<Vec<u8>, Refusal> {
187
    imp::read_mounted_file(path)
188
}
189
190
/// Parse a host `read_file` answer packet: one status byte, then either
191
/// the file bytes (0) or a `{"code", "reason"}` refusal (1).
192
fn parse_host_packet(packet: &[u8]) -> Result<Vec<u8>, Refusal> {
193
    #[derive(serde::Deserialize)]
194
    struct RawRefusal {
195
        code: String,
196
        reason: String,
197
    }
198
    match packet.split_first() {
199
        Some((0, bytes)) => Ok(bytes.to_vec()),
200
        Some((1, body)) => match serde_json::from_slice::<RawRefusal>(body) {
201
            Ok(raw) => match RefusalCode::parse(&raw.code) {
202
                Some(code) => Err(Refusal::new(code, raw.reason)),
203
                None => Err(Refusal::internal(format!("host refusal `{}`: {}", raw.code, raw.reason))),
204
            },
205
            Err(_) => Err(Refusal::internal("the host's refusal packet does not decode")),
206
        },
207
        _ => Err(Refusal::internal("the host answered with an empty packet")),
208
    }
209
}
210
211
#[cfg(target_arch = "wasm32")]
212
mod imp {
213
    use super::{parse_host_packet, Refusal};
214
215
    #[link(wasm_import_module = "openagents")]
216
    extern "C" {
217
        /// Host capability import: `(path_ptr, path_len) -> (ptr << 32) | len`
218
        /// of an answer packet the host wrote into guest memory through
219
        /// `packet_alloc`. Present only when the manifest declares mounts.
220
        fn read_file(path_ptr: *const u8, path_len: u32) -> u64;
221
    }
222
223
    pub fn read_mounted_file(path: &str) -> Result<Vec<u8>, Refusal> {
224
        let packed = unsafe { read_file(path.as_ptr(), path.len() as u32) };
225
        let ptr = (packed >> 32) as u32 as usize as *const u8;
226
        let len = (packed & 0xffff_ffff) as usize;
227
        if ptr.is_null() {
228
            return Err(Refusal::internal("the host answered with a null packet"));
229
        }
230
        let packet = unsafe { core::slice::from_raw_parts(ptr, len) };
231
        parse_host_packet(packet)
232
    }
233
}
234
235
#[cfg(not(target_arch = "wasm32"))]
236
mod imp {
237
    use super::Refusal;
238
239
    pub fn read_mounted_file(_path: &str) -> Result<Vec<u8>, Refusal> {
240
        Err(Refusal::unsupported(
241
            "read_mounted_file is a host capability import; it exists only inside the WASM host",
242
        ))
243
    }
244
}
245
246
/// The pointer plumbing behind [`plugin_entry!`]. Hidden, not private, so
247
/// the macro can reach it from the plugin crate.
248
#[doc(hidden)]
249
pub mod __abi {
250
    use super::{run_handler, Refusal};
251
    use serde::de::DeserializeOwned;
252
    use serde::Serialize;
253
254
    pub fn packet_alloc(len: u32) -> *mut u8 {
255
        let layout = core::alloc::Layout::from_size_align(len.max(1) as usize, 1)
256
            .expect("a byte-aligned layout is always valid");
257
        unsafe { std::alloc::alloc(layout) }
258
    }
259
260
    /// Leak an output packet and pack its location into the return word.
261
    pub fn pack_output(output: Vec<u8>) -> u64 {
262
        let len = output.len() as u64;
263
        let ptr = Box::leak(output.into_boxed_slice()).as_mut_ptr() as u64;
264
        (ptr << 32) | len
265
    }
266
267
    /// # Safety
268
    /// `ptr..ptr+len` must be the packet the host wrote through `packet_alloc`.
269
    pub unsafe fn handle_packet<I, O, F>(ptr: *const u8, len: u32, handler: F) -> u64
270
    where
271
        I: DeserializeOwned,
272
        O: Serialize,
273
        F: FnOnce(I) -> Result<O, Refusal>,
274
    {
275
        let input = core::slice::from_raw_parts(ptr, len as usize);
276
        pack_output(run_handler(input, handler))
277
    }
278
}
279
280
/// Generate the `packet-v0` exports around one typed handler function
281
/// `fn(I) -> Result<O, Refusal>` where `I: Deserialize` and `O: Serialize`.
282
#[macro_export]
283
macro_rules! plugin_entry {
284
    ($handler:path) => {
285
        #[no_mangle]
286
        pub extern "C" fn packet_alloc(len: u32) -> *mut u8 {
287
            $crate::__abi::packet_alloc(len)
288
        }
289
290
        #[no_mangle]
291
        pub extern "C" fn handle_packet(ptr: *const u8, len: u32) -> u64 {
292
            unsafe { $crate::__abi::handle_packet(ptr, len, $handler) }
293
        }
294
    };
295
}
296
297
#[cfg(test)]
298
mod tests {
299
    use super::*;
300
    use serde::{Deserialize, Serialize};
301
302
    #[derive(Deserialize)]
303
    struct In {
304
        n: u32,
305
    }
306
307
    #[derive(Serialize)]
308
    struct Out {
309
        doubled: u32,
310
    }
311
312
    fn double(input: In) -> Result<Out, Refusal> {
313
        if input.n > 1000 {
314
            return Err(Refusal::unsupported("n is too large"));
315
        }
316
        Ok(Out { doubled: input.n * 2 })
317
    }
318
319
    #[test]
320
    fn a_good_packet_comes_back_wrapped_in_ok() {
321
        let packet = run_handler(br#"{"n": 21}"#, double);
322
        assert_eq!(packet, br#"{"ok":{"doubled":42}}"#);
323
    }
324
325
    #[test]
326
    fn an_undecodable_packet_is_a_bad_packet_refusal_not_a_panic() {
327
        let packet = run_handler(b"not json", double);
328
        let value: serde_json::Value = serde_json::from_slice(&packet).unwrap();
329
        assert_eq!(value["refusal"]["code"], "bad_packet");
330
    }
331
332
    #[test]
333
    fn a_handler_refusal_becomes_the_output_packet() {
334
        let packet = run_handler(br#"{"n": 2000}"#, double);
335
        let value: serde_json::Value = serde_json::from_slice(&packet).unwrap();
336
        assert_eq!(value["refusal"]["code"], "unsupported");
337
        assert_eq!(value["refusal"]["reason"], "n is too large");
338
    }
339
340
    #[test]
341
    fn host_ok_packets_carry_the_bytes_after_the_status_byte() {
342
        assert_eq!(parse_host_packet(b"\x00hello"), Ok(b"hello".to_vec()));
343
    }
344
345
    #[test]
346
    fn host_refusal_packets_decode_into_the_typed_enum() {
347
        let packet = b"\x01{\"code\":\"mount_denied\",\"reason\":\"outside\"}";
348
        let refusal = parse_host_packet(packet).unwrap_err();
349
        assert_eq!(refusal.code, RefusalCode::MountDenied);
350
        assert_eq!(refusal.reason, "outside");
351
    }
352
353
    #[test]
354
    fn unknown_host_codes_fold_to_internal_and_keep_the_raw_code() {
355
        let packet = b"\x01{\"code\":\"weather\",\"reason\":\"rain\"}";
356
        let refusal = parse_host_packet(packet).unwrap_err();
357
        assert_eq!(refusal.code, RefusalCode::Internal);
358
        assert!(refusal.reason.contains("weather"));
359
    }
360
361
    #[test]
362
    fn the_return_word_packs_pointer_high_and_length_low() {
363
        let word = __abi::pack_output(vec![1, 2, 3]);
364
        assert_eq!(word & 0xffff_ffff, 3);
365
        assert_ne!(word >> 32, 0);
366
    }
367
368
    #[test]
369
    fn off_wasm_the_mount_import_is_an_unsupported_refusal() {
370
        let refusal = read_mounted_file("anything.txt").unwrap_err();
371
        assert_eq!(refusal.code, RefusalCode::Unsupported);
372
    }
373
}
plugins/word-stats/Cargo.lock deleted -7

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

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 modified +7 -18

@@ -1,24 +1,13 @@

1 1
[package]
2 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]
3
version = "0.2.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin for the OpenAgents coder plugin skeleton: text statistics, bytes in, bytes out, built on the owned PDK."
11 7
12 8
[lib]
13 9
crate-type = ["cdylib"]
14 10
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
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
plugins/word-stats/README.md modified +8 -15

@@ -1,9 +1,10 @@

1 1
# word-stats
2 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.
3
Pure-computation guest plugin: text statistics in, JSON out, no imports.
4
Built on `openagents-pdk` (see `../pdk/`), which owns the whole `packet-v0`
5
ABI — this crate is one typed `handle` function and a `plugin_entry!`
6
invocation. The built artifact `word_stats.wasm` and its digest pin in
7
`manifest.json` are checked in so the plugin runs without a Rust toolchain.
7 8
8 9
Load it into a coder session:
9 10

@@ -13,14 +14,6 @@ printf '/plugin load ../../plugins/word-stats/manifest.json\ncount the words in:

13 14
  | node dist/main.js coder --plain
14 15
```
15 16
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`.
17
Rebuild from `plugins/` (then update `artifact.digest` in `manifest.json`):
18
see `../README.md`. Built with rustc 1.94.1 targeting
19
`wasm32-unknown-unknown`.
plugins/word-stats/manifest.json modified +2 -2

@@ -1,12 +1,12 @@

1 1
{
2 2
  "manifest_version": 1,
3 3
  "name": "word_stats",
4
  "version": "0.1.0",
4
  "version": "0.2.0",
5 5
  "author": "OpenAgents",
6 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 7
  "artifact": {
8 8
    "path": "word_stats.wasm",
9
    "digest": "sha256:7c724f993da2d9de7c1256b020e2c80e6b998cf3c484d123efeebbab8afc9947"
9
    "digest": "sha256:340fc35fec83dfb17d48c12544fbe5d12de5d5689d8dd1ea4745c4dc55a3c33f"
10 10
  },
11 11
  "abi": {
12 12
    "kind": "packet-v0",
plugins/word-stats/src/lib.rs modified +45 -148

@@ -1,57 +1,42 @@

1
//! Demo guest plugin for the OpenAgents coder plugin walking skeleton.
1
//! Text statistics, as a `packet-v0` guest plugin on the owned PDK.
2 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.
3
//! The whole ABI — `packet_alloc`, `handle_packet`, the JSON envelope, the
4
//! return-word packing — lives in `openagents-pdk`. This crate is one typed
5
//! function. Compare the demo predecessor, which carried its own allocator,
6
//! pointer packing, and a hand-rolled JSON scanner.
9 7
//!
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.
8
//! `spin: true` loops forever, existing solely so the host's timeout bound
9
//! is demonstrable against a real runaway guest.
28 10
29
use std::alloc::{alloc, Layout};
11
use openagents_pdk::{plugin_entry, Refusal};
12
use serde::{Deserialize, Serialize};
30 13
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) }
14
#[derive(Deserialize)]
15
struct Input {
16
    text: String,
17
    #[serde(default)]
18
    spin: bool,
35 19
}
36 20
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)
21
#[derive(Serialize)]
22
struct Output {
23
    bytes: usize,
24
    chars: usize,
25
    words: usize,
26
    lines: usize,
27
    longest_word: String,
28
    #[serde(skip_serializing_if = "Option::is_none")]
29
    top_word: Option<TopWord>,
45 30
}
46 31
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") {
32
#[derive(Serialize)]
33
struct TopWord {
34
    word: String,
35
    count: u32,
36
}
37
38
fn handle(input: Input) -> Result<Output, Refusal> {
39
    if input.spin {
55 40
        // A runaway guest, on request, so the host's timeout is testable.
56 41
        let mut n: u64 = 0;
57 42
        loop {

@@ -59,9 +44,7 @@ fn respond(input: &[u8]) -> String {

59 44
        }
60 45
    }
61 46
62
    let bytes = text.len();
63
    let chars = text.chars().count();
64
    let lines = if text.is_empty() { 0 } else { text.lines().count() };
47
    let text = &input.text;
65 48
    let words: Vec<&str> = text.split_whitespace().collect();
66 49
    let longest = words.iter().max_by_key(|word| word.len()).copied().unwrap_or("");
67 50

@@ -80,105 +63,19 @@ fn respond(input: &[u8]) -> String {

80 63
            None => counts.push((lowered, 1)),
81 64
        }
82 65
    }
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
}
66
    let top_word = counts
67
        .into_iter()
68
        .max_by_key(|(_, n)| *n)
69
        .map(|(word, count)| TopWord { word, count });
120 70
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
    }
71
    Ok(Output {
72
        bytes: text.len(),
73
        chars: text.chars().count(),
74
        words: words.len(),
75
        lines: if text.is_empty() { 0 } else { text.lines().count() },
76
        longest_word: longest.to_string(),
77
        top_word,
78
    })
171 79
}
172 80
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
}
81
plugin_entry!(handle);
plugins/word-stats/word_stats.wasm modified

Binary file. Nothing to show as text.

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