Discover foreign coding-agent sessions through a WASM plugin

a2dcddc7c650 · AtlantisPleb · · parent 969c6eb1ff70

Discover foreign coding-agent sessions through a WASM plugin

The scanner half of OpenAgentsInc/openagents.com#198: a foreign_sessions
guest plugin that, given read-only mounts over ~/.claude and ~/.codex,
reports recent Claude Code and Codex CLI session metadata — source, id,
working directory, mtime, size, record count — newest first, filterable
by working directory and age. Discovery only; the resume half is later
work, and the Codex SQLite index (the wasm-sqlite spike) is deferred
deliberately, recorded in the plugin README.

A scanner cannot work on read_file alone, so the host gains its second
and last mount-granted capability import, openagents.list_dir: one
bounded listing (500 entries) per call, named by mount index so a
two-mount scanner never wonders which root answered, confined exactly
as reads are — relative paths only, `..` resolved and checked, symlinks
refused, realpath containment against the realpath'd root. Mount roots
may now also be declared absolute or `~`-relative; either way they must
exist and be directories at load or the plugin refuses to load. The
dir_stats proof plugin drives the escape tests through the real
boundary, the way file_stats proves read_file.

Foreign state is untrusted input, so the guest bounds itself on top of
the host's bounds: at most 50 sessions, 200 file reads, 1500 listings,
5000 candidates, 20 metadata lines per file, 30-day default age cutoff
(against now_ms when given — the sandbox has no clock — else the newest
observed mtime). Failure is soft everywhere: missing stores reported,
malformed and unreadable files skipped and counted, files over the
1 MiB read bound reported from listing metadata alone and marked
metadata_truncated.

The PDK change reshuffles every artifact, so all four are rebuilt
(verified byte-reproducible from a clean target) and repinned. Proven
end to end in the coder chat on real state: /plugin load, then a model
turn that called the tool and summarized this machine's actual recent
sessions — including the session that built it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Opus 5 (1M context) <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

  • modified packages/openagents-cli/src/coder-plugin-engine.ts
  • modified packages/openagents-cli/src/coder-plugins.ts
  • added packages/openagents-cli/test/coder-plugin-foreign-sessions.test.ts
  • added packages/openagents-cli/test/coder-plugin-list-dir.test.ts
  • modified plugins/Cargo.lock
  • modified plugins/Cargo.toml
  • modified plugins/README.md
  • added plugins/dir-stats/Cargo.toml
  • added plugins/dir-stats/README.md
  • added plugins/dir-stats/data/sample.txt
  • added plugins/dir-stats/dir_stats.wasm
  • added plugins/dir-stats/manifest.json
  • added plugins/dir-stats/src/lib.rs
  • modified plugins/file-stats/file_stats.wasm
  • modified plugins/file-stats/manifest.json
  • added plugins/foreign-sessions/Cargo.toml
  • added plugins/foreign-sessions/README.md
  • added plugins/foreign-sessions/foreign_sessions.wasm
  • added plugins/foreign-sessions/manifest.json
  • added plugins/foreign-sessions/src/lib.rs
  • added plugins/foreign-sessions/src/tests.rs
  • modified plugins/pdk/src/lib.rs
  • modified plugins/word-stats/manifest.json
  • modified plugins/word-stats/word_stats.wasm

Diff

24 files changed, +1831 -48

packages/openagents-cli/src/coder-plugin-engine.ts modified +98 -12

@@ -21,11 +21,12 @@

21 21
 * survives between calls.
22 22
 *
23 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.
24
 * mounts, the worker exposes exactly two imports — `openagents.read_file`
25
 * and `openagents.list_dir` — and confines every path: relative to a
26
 * declared root only, `..` resolved and checked, symlinks refused, a
27
 * per-file size bound on reads, and a per-listing entry bound on listings.
28
 * The answer crosses back as a status-prefixed packet the PDK decodes:
29
 * `0x00` + bytes, or `0x01` + a `{"code", "reason"}` refusal.
29 30
 */
30 31
31 32
import { Worker } from "node:worker_threads";

@@ -86,6 +87,8 @@ export interface EngineJob {

86 87
  readonly mounts: ReadonlyArray<string>;
87 88
  /** Per-file byte bound for mounted reads. */
88 89
  readonly mountFileLimit: number;
90
  /** Per-listing entry bound for mounted directory listings. */
91
  readonly mountDirEntryLimit: number;
89 92
  /** Cancels the invocation the same way the timeout does. */
90 93
  readonly signal?: AbortSignal | undefined;
91 94
}

@@ -121,10 +124,10 @@ export interface PluginEngine {

121 124
 */
122 125
const INVOKE_WORKER = `
123 126
const { parentPort, workerData } = require("node:worker_threads");
124
const { lstatSync, readFileSync, realpathSync } = require("node:fs");
125
const { isAbsolute, resolve, sep } = require("node:path");
127
const { lstatSync, readdirSync, readFileSync, realpathSync } = require("node:fs");
128
const { isAbsolute, join, resolve, sep } = require("node:path");
126 129
(async () => {
127
  const { wasm, input, entry, alloc, mounts, mountFileLimit } = workerData;
130
  const { wasm, input, entry, alloc, mounts, mountFileLimit, mountDirEntryLimit } = workerData;
128 131
  try {
129 132
    let guest = null;
130 133

@@ -188,6 +191,77 @@ const { isAbsolute, resolve, sep } = require("node:path");

188 191
      return refusalPacket("mount_denied", "no declared mount contains the path");
189 192
    };
190 193
194
    // List one directory inside one declared mount, by mount index. The
195
    // index makes the target root explicit — a scanner over two mounts
196
    // (say ~/.claude and ~/.codex) must never have "which root answered?"
197
    // ambiguity for a listing. Same confinement as readMounted, plus an
198
    // entry bound instead of a byte bound.
199
    const listMounted = (mountIndex, path) => {
200
      if (!Number.isInteger(mountIndex) || mountIndex < 0 || mountIndex >= mounts.length) {
201
        return refusalPacket("mount_denied", "the mount index names no declared mount");
202
      }
203
      if (isAbsolute(path)) {
204
        return refusalPacket("mount_denied", "absolute paths are refused; mounted paths are relative to a declared mount root");
205
      }
206
      const root = mounts[mountIndex];
207
      const candidate = resolve(root, path);
208
      if (candidate !== root && !candidate.startsWith(root + sep)) {
209
        return refusalPacket("mount_denied", "the path escapes the mount root");
210
      }
211
      let stat;
212
      try {
213
        stat = lstatSync(candidate);
214
      } catch {
215
        return refusalPacket("file_unreadable", "the mount has no such directory");
216
      }
217
      if (stat.isSymbolicLink()) {
218
        return refusalPacket("mount_denied", "symlinks inside a mount are refused");
219
      }
220
      if (!stat.isDirectory()) {
221
        return refusalPacket("file_unreadable", "the path is not a directory");
222
      }
223
      let real;
224
      try {
225
        real = realpathSync(candidate);
226
      } catch (cause) {
227
        return refusalPacket("file_unreadable", String((cause && cause.message) || cause));
228
      }
229
      if (real !== root && !real.startsWith(root + sep)) {
230
        return refusalPacket("mount_denied", "the path resolves outside the mount root");
231
      }
232
      let names;
233
      try {
234
        names = readdirSync(candidate);
235
      } catch (cause) {
236
        return refusalPacket("file_unreadable", String((cause && cause.message) || cause));
237
      }
238
      names.sort();
239
      const truncated = names.length > mountDirEntryLimit;
240
      const entries = [];
241
      for (const name of names.slice(0, mountDirEntryLimit)) {
242
        let kind = "other";
243
        let size = 0;
244
        let mtimeMs = 0;
245
        try {
246
          const entryStat = lstatSync(join(candidate, name));
247
          kind = entryStat.isSymbolicLink()
248
            ? "symlink"
249
            : entryStat.isFile()
250
              ? "file"
251
              : entryStat.isDirectory()
252
                ? "dir"
253
                : "other";
254
          size = entryStat.size;
255
          mtimeMs = Math.floor(entryStat.mtimeMs);
256
        } catch {
257
          // A racing unlink between readdir and lstat: report the name as
258
          // "other" so the guest can skip it, rather than failing the listing.
259
        }
260
        entries.push({ name, kind, size, mtime_ms: mtimeMs });
261
      }
262
      return okPacket(new TextEncoder().encode(JSON.stringify({ entries, truncated })));
263
    };
264
191 265
    // Write an answer packet into guest memory through the guest's own
192 266
    // allocator and pack its location the way handle_packet does.
193 267
    const answerGuest = (packet) => {

@@ -196,18 +270,29 @@ const { isAbsolute, resolve, sep } = require("node:path");

196 270
      return (BigInt(ptr) << 32n) | BigInt(packet.length);
197 271
    };
198 272
199
    // The capability import exists only when the manifest declared mounts;
273
    // The capability imports exist only when the manifest declared mounts;
200 274
    // the loader has already refused any module that asks for more.
275
    const guestPath = (pathPtr, pathLen) => {
276
      const memory = new Uint8Array(guest.memory.buffer);
277
      return new TextDecoder().decode(memory.slice(pathPtr, pathPtr + pathLen));
278
    };
201 279
    const imports =
202 280
      mounts.length > 0
203 281
        ? {
204 282
            openagents: {
205 283
              read_file: (pathPtr, pathLen) => {
206
                const memory = new Uint8Array(guest.memory.buffer);
207
                const path = new TextDecoder().decode(memory.slice(pathPtr, pathPtr + pathLen));
208 284
                let packet;
209 285
                try {
210
                  packet = readMounted(path);
286
                  packet = readMounted(guestPath(pathPtr, pathLen));
287
                } catch (cause) {
288
                  packet = refusalPacket("file_unreadable", String((cause && cause.message) || cause));
289
                }
290
                return answerGuest(packet);
291
              },
292
              list_dir: (mountIndex, pathPtr, pathLen) => {
293
                let packet;
294
                try {
295
                  packet = listMounted(mountIndex, guestPath(pathPtr, pathLen));
211 296
                } catch (cause) {
212 297
                  packet = refusalPacket("file_unreadable", String((cause && cause.message) || cause));
213 298
                }

@@ -276,6 +361,7 @@ export const nodeWorkerEngine: PluginEngine = {

276 361
          alloc: job.alloc,
277 362
          mounts: [...job.mounts],
278 363
          mountFileLimit: job.mountFileLimit,
364
          mountDirEntryLimit: job.mountDirEntryLimit,
279 365
        },
280 366
      });
281 367
packages/openagents-cli/src/coder-plugins.ts modified +41 -13

@@ -18,14 +18,16 @@

18 18
 *   not a warning.
19 19
 * - **Imports must be declared.** A module's import list must be covered by
20 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.
21
 *   exactly `openagents.read_file` plus `openagents.list_dir` when the
22
 *   manifest declares read-only mounts. Anything else is refused by
23
 *   inspection, before instantiation, so the sandbox is a property of what
24
 *   was loaded rather than a hope about what it does.
25 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.
26
 *   real directory at load (relative to the manifest, absolute, or
27
 *   `~`-expanded); at invocation the engine's `read_file` and `list_dir`
28
 *   imports canonicalize every path, refuse absolute paths, `..` escapes,
29
 *   and symlinks, bound the bytes per file, and bound the entries per
30
 *   listing.
29 31
 * - **Limits are the engine's job.** Timeout by termination, cancellation
30 32
 *   the same way. See {@link PluginEngine} in `coder-plugin-engine.ts`.
31 33
 * - **Typed refusals both ways.** The host refuses with `{code, reason}`;

@@ -36,7 +38,8 @@

36 38
37 39
import { createHash } from "node:crypto";
38 40
import { readFileSync, realpathSync, statSync } from "node:fs";
39
import { dirname, resolve } from "node:path";
41
import { homedir } from "node:os";
42
import { dirname, join, resolve } from "node:path";
40 43
41 44
import {
42 45
  defaultEngine,

@@ -54,12 +57,31 @@ export const SUPPORTED_ABI = "packet-v0";

54 57
55 58
/** A read-only directory grant, as the manifest declares it. */
56 59
export interface PluginMount {
57
  /** Directory path, resolved relative to the manifest's directory. */
60
  /**
61
   * Directory path. Relative paths resolve against the manifest's
62
   * directory; absolute paths are taken as-is; a leading `~/` (or a bare
63
   * `~`) expands to the invoking user's home directory. Whatever the form,
64
   * the root must exist and be a directory at load, or the plugin refuses
65
   * to load.
66
   */
58 67
  readonly path: string;
59 68
  /** Only `true` is accepted; a writable mount is refused, not downgraded. */
60 69
  readonly readonly: true;
61 70
}
62 71
72
/**
73
 * Expand a manifest mount path's `~` prefix to the user's home directory.
74
 *
75
 * Only a bare `~` or a `~/...` prefix expands — `~alice/...` is somebody
76
 * else's home and stays literal, which then fails the exists-and-is-a-
77
 * directory check rather than silently reading another account.
78
 */
79
export function expandMountPath(path: string): string {
80
  if (path === "~") return homedir();
81
  if (path.startsWith("~/")) return join(homedir(), path.slice(2));
82
  return path;
83
}
84
63 85
/** The manifest fields this host reads. The file may carry more. */
64 86
export interface PluginManifest {
65 87
  readonly name: string;

@@ -95,6 +117,9 @@ const TIMEOUT_CEILING_MS = 30_000;

95 117
/** Per-file byte bound for reads through a mount. */
96 118
export const MOUNT_FILE_LIMIT = 1_048_576;
97 119
120
/** Entry bound per directory listing through a mount; the rest is truncated. */
121
export const MOUNT_DIR_ENTRY_LIMIT = 500;
122
98 123
/** How much plugin output the model is shown. */
99 124
const PLUGIN_OUTPUT_LIMIT = 16_000;
100 125

@@ -140,7 +165,7 @@ export function loadPluginFromManifest(

140 165
  const manifestDir = dirname(manifestPath);
141 166
  const mounts: string[] = [];
142 167
  for (const mount of manifest.capabilities.mounts) {
143
    const declared = resolve(manifestDir, mount.path);
168
    const declared = resolve(manifestDir, expandMountPath(mount.path));
144 169
    let root: string;
145 170
    try {
146 171
      root = realpathSync(declared);

@@ -176,13 +201,15 @@ export function loadPluginFromManifest(

176 201
  if (isRefusal(shape)) return shape;
177 202
178 203
  // 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"] : []);
204
  // exactly two: the read_file and list_dir capability imports.
205
  const granted = new Set(
206
    mounts.length > 0 ? ["openagents.read_file", "openagents.list_dir"] : [],
207
  );
181 208
  const undeclared = shape.imports.filter((name) => !granted.has(name));
182 209
  if (undeclared.length > 0) {
183 210
    const grantHint =
184 211
      mounts.length > 0
185
        ? "the declared mounts grant only `openagents.read_file`"
212
        ? "the declared mounts grant only `openagents.read_file` and `openagents.list_dir`"
186 213
        : "the manifest declares no capabilities, so the module may import nothing";
187 214
    return refuse(
188 215
      "imports_undeclared",

@@ -330,6 +357,7 @@ export function invokePlugin(

330 357
    timeoutMs: options?.timeoutMs ?? plugin.manifest.capabilities.timeout_ms,
331 358
    mounts: plugin.mounts,
332 359
    mountFileLimit: MOUNT_FILE_LIMIT,
360
    mountDirEntryLimit: MOUNT_DIR_ENTRY_LIMIT,
333 361
    signal: options?.signal,
334 362
  });
335 363
}
packages/openagents-cli/test/coder-plugin-foreign-sessions.test.ts added +255

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

1
/**
2
 * The foreign-session scanner through the real boundary: the checked-in
3
 * `foreign_sessions` plugin against staged fixture trees shaped like
4
 * `~/.claude` and `~/.codex`, with good, malformed, oversized, and
5
 * symlinked entries. The scanner's own logic is unit-tested against a
6
 * fake host in `plugins/foreign-sessions/src/tests.rs`; this file proves
7
 * the same behavior holds through the WASM sandbox and the host's
8
 * confined `read_file` and `list_dir` imports, over absolute mount roots.
9
 */
10
11
import {
12
  copyFileSync,
13
  mkdirSync,
14
  mkdtempSync,
15
  readFileSync,
16
  symlinkSync,
17
  utimesSync,
18
  writeFileSync,
19
} from "node:fs";
20
import { tmpdir } from "node:os";
21
import { join } from "node:path";
22
import { fileURLToPath } from "node:url";
23
24
import { describe, expect, it } from "vitest";
25
26
import {
27
  MOUNT_FILE_LIMIT,
28
  invokePlugin,
29
  isRefusal,
30
  loadPluginFromManifest,
31
  type LoadedPlugin,
32
} from "../src/coder-plugins.js";
33
34
const MANIFEST = fileURLToPath(
35
  new URL("../../../plugins/foreign-sessions/manifest.json", import.meta.url),
36
);
37
const WASM = fileURLToPath(
38
  new URL("../../../plugins/foreign-sessions/foreign_sessions.wasm", import.meta.url),
39
);
40
41
const NOW_MS = Date.now();
42
const DAY_MS = 86_400_000;
43
44
const claudeRecord = (cwd: string, sessionId: string): string =>
45
  `${JSON.stringify({
46
    type: "user",
47
    cwd,
48
    sessionId,
49
    message: { role: "user", content: "hello" },
50
  })}\n`;
51
52
const codexMeta = (cwd: string, id: string): string =>
53
  `${JSON.stringify({
54
    timestamp: "2026-08-20T10:00:00.000Z",
55
    type: "session_meta",
56
    payload: { id, cwd },
57
  })}\n`;
58
59
const touch = (path: string, mtimeMs: number): void => {
60
  utimesSync(path, new Date(mtimeMs), new Date(mtimeMs));
61
};
62
63
/**
64
 * Stage fixture `~/.claude` and `~/.codex` trees plus a manifest copy
65
 * whose mounts point at them by absolute path, and load the plugin.
66
 */
67
const stage = (): { plugin: LoadedPlugin; claudeRoot: string; codexRoot: string } => {
68
  const dir = mkdtempSync(join(tmpdir(), "foreign-sessions-"));
69
  const claudeRoot = join(dir, "dot-claude");
70
  const codexRoot = join(dir, "dot-codex");
71
72
  // Claude: one good recent session, one malformed, one oversized, one
73
  // symlinked, one stale (40 days old); a second project for cwd filtering.
74
  const projectA = join(claudeRoot, "projects", "-Users-ada-work-alpha");
75
  mkdirSync(projectA, { recursive: true });
76
  writeFileSync(
77
    join(projectA, "good.jsonl"),
78
    claudeRecord("/Users/ada/work/alpha", "good") + claudeRecord("/Users/ada/work/alpha", "good"),
79
  );
80
  touch(join(projectA, "good.jsonl"), NOW_MS - DAY_MS);
81
  writeFileSync(join(projectA, "malformed.jsonl"), "this is not json\n{}\n");
82
  touch(join(projectA, "malformed.jsonl"), NOW_MS - DAY_MS);
83
  writeFileSync(join(projectA, "huge.jsonl"), Buffer.alloc(MOUNT_FILE_LIMIT + 1, 0x7b));
84
  touch(join(projectA, "huge.jsonl"), NOW_MS - 2 * DAY_MS);
85
  writeFileSync(join(dir, "outside.jsonl"), claudeRecord("/elsewhere", "outside"));
86
  symlinkSync(join(dir, "outside.jsonl"), join(projectA, "sneaky.jsonl"));
87
  writeFileSync(join(projectA, "stale.jsonl"), claudeRecord("/Users/ada/work/alpha", "stale"));
88
  touch(join(projectA, "stale.jsonl"), NOW_MS - 40 * DAY_MS);
89
90
  const projectB = join(claudeRoot, "projects", "-Users-ada-work-beta");
91
  mkdirSync(projectB, { recursive: true });
92
  writeFileSync(join(projectB, "other.jsonl"), claudeRecord("/Users/ada/work/beta", "other"));
93
  touch(join(projectB, "other.jsonl"), NOW_MS - 3 * DAY_MS);
94
95
  // Codex: one good recent rollout and one malformed one.
96
  const day = join(codexRoot, "sessions", "2026", "08", "20");
97
  mkdirSync(day, { recursive: true });
98
  writeFileSync(
99
    join(day, "rollout-2026-08-20T10-00-00-abc.jsonl"),
100
    codexMeta("/Users/ada/work/gamma", "abc"),
101
  );
102
  touch(join(day, "rollout-2026-08-20T10-00-00-abc.jsonl"), NOW_MS - DAY_MS / 2);
103
  writeFileSync(join(day, "rollout-2026-08-20T11-00-00-bad.jsonl"), "not a rollout\n");
104
  touch(join(day, "rollout-2026-08-20T11-00-00-bad.jsonl"), NOW_MS - DAY_MS / 2);
105
106
  const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
107
    capabilities: { mounts: Array<{ path: string; readonly: true }> };
108
  };
109
  manifest.capabilities.mounts = [
110
    { path: claudeRoot, readonly: true },
111
    { path: codexRoot, readonly: true },
112
  ];
113
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
114
  copyFileSync(WASM, join(dir, "foreign_sessions.wasm"));
115
116
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
117
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
118
  return { plugin: outcome, claudeRoot, codexRoot };
119
};
120
121
type Session = {
122
  source: string;
123
  session_id: string;
124
  path: string;
125
  cwd?: string;
126
  project_dir?: string;
127
  mtime_ms: number;
128
  size_bytes: number;
129
  record_count?: number;
130
  metadata_truncated?: boolean;
131
};
132
133
type Output = {
134
  ok?: {
135
    sessions: Session[];
136
    skipped: { malformed: number; unreadable: number; symlinked: number };
137
    oversized: number;
138
    missing_sources?: string[];
139
    scan_truncated: boolean;
140
    read_budget_exhausted: boolean;
141
  };
142
  refusal?: { code: string; reason: string };
143
};
144
145
const run = async (plugin: LoadedPlugin, args: Record<string, unknown>): Promise<Output> => {
146
  const packet = new TextEncoder().encode(JSON.stringify({ now_ms: NOW_MS, ...args }));
147
  const outcome = await invokePlugin(plugin, packet);
148
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
149
  return JSON.parse(new TextDecoder().decode(outcome)) as Output;
150
};
151
152
describe("foreign session discovery", () => {
153
  it("discovers recent sessions from both stores, newest first, with metadata", async () => {
154
    const { plugin } = stage();
155
    const { ok } = await run(plugin, {});
156
    expect(ok).toBeDefined();
157
158
    const ids = ok?.sessions.map((s) => s.session_id);
159
    expect(ids).toEqual(["abc", "good", "huge", "other"]);
160
161
    const good = ok?.sessions.find((s) => s.session_id === "good");
162
    expect(good).toMatchObject({
163
      source: "claude",
164
      cwd: "/Users/ada/work/alpha",
165
      project_dir: "-Users-ada-work-alpha",
166
      path: "projects/-Users-ada-work-alpha/good.jsonl",
167
      record_count: 2,
168
    });
169
    expect(good?.metadata_truncated).toBeUndefined();
170
    expect(good?.size_bytes).toBeGreaterThan(0);
171
172
    const codex = ok?.sessions.find((s) => s.session_id === "abc");
173
    expect(codex).toMatchObject({
174
      source: "codex",
175
      cwd: "/Users/ada/work/gamma",
176
      path: "sessions/2026/08/20/rollout-2026-08-20T10-00-00-abc.jsonl",
177
      record_count: 1,
178
    });
179
  });
180
181
  it("fails soft: malformed skipped and counted, oversized kept but truncated, symlinks refused", async () => {
182
    const { plugin } = stage();
183
    const { ok } = await run(plugin, {});
184
185
    // malformed.jsonl (claude) and the bad rollout (codex).
186
    expect(ok?.skipped.malformed).toBe(2);
187
    // sneaky.jsonl, reported by the listing as a symlink and never read.
188
    expect(ok?.skipped.symlinked).toBe(1);
189
190
    const huge = ok?.sessions.find((s) => s.session_id === "huge");
191
    expect(huge?.metadata_truncated).toBe(true);
192
    expect(huge?.cwd).toBeUndefined();
193
    expect(huge?.record_count).toBeUndefined();
194
    expect(huge?.size_bytes).toBe(MOUNT_FILE_LIMIT + 1);
195
    expect(ok?.oversized).toBe(1);
196
  });
197
198
  it("drops sessions older than the age cutoff", async () => {
199
    const { plugin } = stage();
200
    const { ok } = await run(plugin, {});
201
    expect(ok?.sessions.some((s) => s.session_id === "stale")).toBe(false);
202
203
    const wide = await run(plugin, { max_age_days: 365 });
204
    expect(wide.ok?.sessions.some((s) => s.session_id === "stale")).toBe(true);
205
  });
206
207
  it("narrows to a working directory with cwd_filter", async () => {
208
    const { plugin } = stage();
209
    const { ok } = await run(plugin, { cwd_filter: "work/beta" });
210
    expect(ok?.sessions.map((s) => s.session_id)).toEqual(["other"]);
211
  });
212
213
  it("honors limit and source selection", async () => {
214
    const { plugin } = stage();
215
    const one = await run(plugin, { limit: 1 });
216
    expect(one.ok?.sessions).toHaveLength(1);
217
218
    const codexOnly = await run(plugin, { sources: ["codex"] });
219
    expect(codexOnly.ok?.sessions.map((s) => s.source)).toEqual(["codex"]);
220
  });
221
222
  it("reports a store that is not present instead of failing", async () => {
223
    const { plugin } = stage();
224
    // The codex mount exists but holds no `sessions` directory in this
225
    // staging; rebuild the fixture without it.
226
    const dir = mkdtempSync(join(tmpdir(), "foreign-sessions-empty-"));
227
    const claudeRoot = join(dir, "dot-claude");
228
    const codexRoot = join(dir, "dot-codex");
229
    mkdirSync(join(claudeRoot, "projects"), { recursive: true });
230
    mkdirSync(codexRoot, { recursive: true });
231
    const manifest = JSON.parse(readFileSync(MANIFEST, "utf8")) as {
232
      capabilities: { mounts: Array<{ path: string; readonly: true }> };
233
    };
234
    manifest.capabilities.mounts = [
235
      { path: claudeRoot, readonly: true },
236
      { path: codexRoot, readonly: true },
237
    ];
238
    writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
239
    copyFileSync(WASM, join(dir, "foreign_sessions.wasm"));
240
    const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
241
    if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
242
243
    const { ok } = await run(outcome, {});
244
    expect(ok?.sessions).toEqual([]);
245
    expect(ok?.missing_sources).toEqual(["codex"]);
246
    void plugin;
247
  });
248
249
  it("refuses an unknown source as a typed guest refusal", async () => {
250
    const { plugin } = stage();
251
    const answer = await run(plugin, { sources: ["cursor"] });
252
    expect(answer.refusal?.code).toBe("unsupported");
253
    expect(answer.refusal?.reason).toContain("cursor");
254
  });
255
});
packages/openagents-cli/test/coder-plugin-list-dir.test.ts added +200

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

1
/**
2
 * Mounted directory listings, proved against the checked-in `dir_stats`
3
 * plugin: a declared mount grants the `openagents.list_dir` capability
4
 * import, confinement refuses every escape (`..`, absolute paths,
5
 * symlinks, out-of-range mount indices), the per-listing entry bound
6
 * holds, and mount roots may be declared absolute or `~`-relative.
7
 */
8
9
import {
10
  copyFileSync,
11
  mkdirSync,
12
  mkdtempSync,
13
  readFileSync,
14
  symlinkSync,
15
  writeFileSync,
16
} from "node:fs";
17
import { homedir, 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_DIR_ENTRY_LIMIT,
25
  expandMountPath,
26
  invokePlugin,
27
  isRefusal,
28
  loadPluginFromManifest,
29
  type LoadedPlugin,
30
} from "../src/coder-plugins.js";
31
32
const DIR_STATS_MANIFEST = fileURLToPath(
33
  new URL("../../../plugins/dir-stats/manifest.json", import.meta.url),
34
);
35
const DIR_STATS_WASM = fileURLToPath(
36
  new URL("../../../plugins/dir-stats/dir_stats.wasm", import.meta.url),
37
);
38
39
/**
40
 * Stage a private copy of the dir_stats plugin with an empty `data/`
41
 * mount, so a test can shape the mount's contents and the manifest.
42
 */
43
const stage = (mutateManifest?: (manifest: Record<string, unknown>) => void): string => {
44
  const dir = mkdtempSync(join(tmpdir(), "plugin-listdir-"));
45
  const manifest = JSON.parse(readFileSync(DIR_STATS_MANIFEST, "utf8")) as Record<
46
    string,
47
    unknown
48
  >;
49
  mutateManifest?.(manifest);
50
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
51
  copyFileSync(DIR_STATS_WASM, join(dir, "dir_stats.wasm"));
52
  mkdirSync(join(dir, "data"));
53
  return dir;
54
};
55
56
const load = (dir: string): LoadedPlugin => {
57
  const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
58
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
59
  return outcome;
60
};
61
62
/** Invoke dir_stats for one listing and parse the output packet. */
63
const list = async (
64
  plugin: LoadedPlugin,
65
  path: string,
66
  mountIndex = 0,
67
): Promise<Record<string, Record<string, unknown> | undefined>> => {
68
  const packet = new TextEncoder().encode(JSON.stringify({ mount_index: mountIndex, path }));
69
  const outcome = await invokePlugin(plugin, packet);
70
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
71
  return JSON.parse(new TextDecoder().decode(outcome)) as Record<
72
    string,
73
    Record<string, unknown> | undefined
74
  >;
75
};
76
77
type Entry = { name: string; kind: string; size: number; mtime_ms: number };
78
79
describe("mounted directory listings", () => {
80
  it("lists the mount root with names, kinds, sizes, and mtimes", async () => {
81
    const dir = stage();
82
    writeFileSync(join(dir, "data", "a.jsonl"), "one\n");
83
    mkdirSync(join(dir, "data", "nested"));
84
    const plugin = load(dir);
85
86
    const answer = await list(plugin, "");
87
    const entries = answer["ok"]?.["entries"] as Entry[];
88
    expect(entries.map((e) => [e.name, e.kind])).toEqual([
89
      ["a.jsonl", "file"],
90
      ["nested", "dir"],
91
    ]);
92
    expect(entries[0]?.size).toBe(4);
93
    expect(entries[0]?.mtime_ms).toBeGreaterThan(0);
94
    expect(answer["ok"]?.["truncated"]).toBe(false);
95
  });
96
97
  it("lists a subdirectory and reports symlink entries without following them", async () => {
98
    const dir = stage();
99
    mkdirSync(join(dir, "data", "nested"));
100
    writeFileSync(join(dir, "secret.txt"), "outside");
101
    symlinkSync(join(dir, "secret.txt"), join(dir, "data", "nested", "sneaky"));
102
    const plugin = load(dir);
103
104
    const answer = await list(plugin, "nested");
105
    const entries = answer["ok"]?.["entries"] as Entry[];
106
    expect(entries).toHaveLength(1);
107
    expect(entries[0]).toMatchObject({ name: "sneaky", kind: "symlink" });
108
  });
109
110
  it("refuses a `..` path that escapes the mount root", async () => {
111
    const plugin = load(stage());
112
    const answer = await list(plugin, "..");
113
    expect(answer["refusal"]?.["code"]).toBe("mount_denied");
114
  });
115
116
  it("refuses an absolute path", async () => {
117
    const plugin = load(stage());
118
    const answer = await list(plugin, "/etc");
119
    expect(answer["refusal"]?.["code"]).toBe("mount_denied");
120
  });
121
122
  it("refuses to list through a symlinked directory", async () => {
123
    const dir = stage();
124
    mkdirSync(join(dir, "outside"));
125
    symlinkSync(join(dir, "outside"), join(dir, "data", "portal"));
126
    const plugin = load(dir);
127
128
    const answer = await list(plugin, "portal");
129
    expect(answer["refusal"]?.["code"]).toBe("mount_denied");
130
  });
131
132
  it("refuses a mount index that names no declared mount", async () => {
133
    const plugin = load(stage());
134
    const answer = await list(plugin, "", 7);
135
    expect(answer["refusal"]?.["code"]).toBe("mount_denied");
136
  });
137
138
  it("answers a missing directory with a refusal, not a trap", async () => {
139
    const plugin = load(stage());
140
    const answer = await list(plugin, "no-such-dir");
141
    expect(answer["refusal"]?.["code"]).toBe("file_unreadable");
142
  });
143
144
  it("answers a file path with a refusal: only directories list", async () => {
145
    const dir = stage();
146
    writeFileSync(join(dir, "data", "plain.txt"), "x");
147
    const plugin = load(dir);
148
    const answer = await list(plugin, "plain.txt");
149
    expect(answer["refusal"]?.["code"]).toBe("file_unreadable");
150
  });
151
152
  it("bounds the entries per listing and says so", async () => {
153
    const dir = stage();
154
    for (let i = 0; i < MOUNT_DIR_ENTRY_LIMIT + 1; i += 1) {
155
      writeFileSync(join(dir, "data", `f${String(i).padStart(4, "0")}`), "");
156
    }
157
    const plugin = load(dir);
158
159
    const answer = await list(plugin, "");
160
    const entries = answer["ok"]?.["entries"] as Entry[] | undefined;
161
    expect(entries).toHaveLength(MOUNT_DIR_ENTRY_LIMIT);
162
    expect(answer["ok"]?.["truncated"]).toBe(true);
163
  });
164
});
165
166
describe("mount root declarations", () => {
167
  it("accepts an absolute mount root that exists and is a directory", async () => {
168
    const outside = mkdtempSync(join(tmpdir(), "plugin-absmount-"));
169
    writeFileSync(join(outside, "here.txt"), "hi");
170
    const dir = stage((manifest) => {
171
      (manifest["capabilities"] as Record<string, unknown>)["mounts"] = [
172
        { path: outside, readonly: true },
173
      ];
174
    });
175
    const plugin = load(dir);
176
    expect(plugin.mounts).toHaveLength(1);
177
178
    const answer = await list(plugin, "");
179
    const entries = answer["ok"]?.["entries"] as Entry[];
180
    expect(entries.map((e) => e.name)).toContain("here.txt");
181
  });
182
183
  it("refuses an absolute mount root that does not exist", () => {
184
    const dir = stage((manifest) => {
185
      (manifest["capabilities"] as Record<string, unknown>)["mounts"] = [
186
        { path: join(tmpdir(), "definitely-not-a-real-mount-root"), readonly: true },
187
      ];
188
    });
189
    const outcome = loadPluginFromManifest(join(dir, "manifest.json"));
190
    expect(isRefusal(outcome) && outcome.code).toBe("mount_invalid");
191
  });
192
193
  it("expands `~` and `~/` to the invoking user's home, and nothing else", () => {
194
    expect(expandMountPath("~")).toBe(homedir());
195
    expect(expandMountPath("~/.claude")).toBe(join(homedir(), ".claude"));
196
    expect(expandMountPath("~alice/secrets")).toBe("~alice/secrets");
197
    expect(expandMountPath("data")).toBe("data");
198
    expect(expandMountPath("/absolute")).toBe("/absolute");
199
  });
200
});
plugins/Cargo.lock modified +17

@@ -2,6 +2,14 @@

2 2
# It is not intended for manual editing.
3 3
version = 4
4 4
5
[[package]]
6
name = "dir-stats"
7
version = "0.1.0"
8
dependencies = [
9
 "openagents-pdk",
10
 "serde",
11
]
12
5 13
[[package]]
6 14
name = "file-stats"
7 15
version = "0.1.0"

@@ -10,6 +18,15 @@ dependencies = [

10 18
 "serde",
11 19
]
12 20
21
[[package]]
22
name = "foreign-sessions"
23
version = "0.1.0"
24
dependencies = [
25
 "openagents-pdk",
26
 "serde",
27
 "serde_json",
28
]
29
13 30
[[package]]
14 31
name = "itoa"
15 32
version = "1.0.18"
plugins/Cargo.toml modified +1 -1

@@ -13,7 +13,7 @@

13 13
14 14
[workspace]
15 15
resolver = "2"
16
members = ["pdk", "word-stats", "file-stats"]
16
members = ["pdk", "word-stats", "file-stats", "dir-stats", "foreign-sessions"]
17 17
18 18
[workspace.package]
19 19
edition = "2021"
plugins/README.md modified +17 -7

@@ -12,13 +12,19 @@ OpenAgentsInc/openagents#26 and

12 12
- `pdk/` — `openagents-pdk`, the library every guest builds on. It owns the
13 13
  `packet-v0` ABI (`packet_alloc`, `handle_packet`, the JSON envelope, the
14 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)`.
15
  imports (`read_mounted_file`, `list_mounted_dir`). A plugin author
16
  writes one `fn handle(input) -> Result<Output, Refusal>` over serde
17
  types and invokes `plugin_entry!(handle)`.
18 18
- `word-stats/` — pure computation: no imports, text statistics.
19 19
- `file-stats/` — the read-only-mount proof: imports exactly
20 20
  `openagents.read_file`, which the host exposes only because its manifest
21 21
  declares a mount.
22
- `dir-stats/` — the listing proof: imports exactly `openagents.list_dir`,
23
  the second and last capability a mount declaration grants.
24
- `foreign-sessions/` — the first working plugin over both capabilities:
25
  discovers recent Claude Code and Codex CLI sessions from `~/.claude` and
26
  `~/.codex` mounted read-only, metadata only. The scanner half of
27
  OpenAgentsInc/openagents.com#198.
22 28
23 29
Each plugin's built `.wasm` artifact and its `sha256:` digest pin are
24 30
checked in beside the source, so the CLI runs them without a Rust

@@ -30,13 +36,17 @@ From this directory:

30 36
31 37
```sh
32 38
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
39
for name in word_stats file_stats dir_stats foreign_sessions; do
40
  crate=$(printf '%s' "$name" | tr _ -)
41
  cp "target/wasm32-unknown-unknown/release/$name.wasm" "$crate/$name.wasm"
42
done
43
shasum -a 256 */*.wasm
36 44
```
37 45
38 46
Then update each manifest's `artifact.digest` — the host refuses a stale
39
pin — and rerun the plugin tests in `packages/openagents-cli`.
47
pin — and rerun the plugin tests in `packages/openagents-cli`. A change
48
to the PDK reshuffles every artifact's bytes, so rebuild and repin all of
49
them together, never one alone.
40 50
41 51
The checked-in artifacts were built with rustc 1.94.1 targeting
42 52
`wasm32-unknown-unknown` (`rustup target add wasm32-unknown-unknown`),
plugins/dir-stats/Cargo.toml added +13

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

1
[package]
2
name = "dir-stats"
3
version = "0.1.0"
4
edition.workspace = true
5
license.workspace = true
6
description = "Guest plugin proving mounted directory listings: one bounded listing through the host's confined list_dir capability import."
7
8
[lib]
9
crate-type = ["cdylib"]
10
11
[dependencies]
12
openagents-pdk = { workspace = true }
13
serde = { workspace = true }
plugins/dir-stats/README.md added +9

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

1
# dir_stats
2
3
The proof plugin for the host's second capability import,
4
`openagents.list_dir`: one bounded directory listing through a declared
5
read-only mount, named by mount index and mount-relative path. It reads no
6
file contents and reaches nothing outside the mount; the escape tests in
7
`packages/openagents-cli/test/coder-plugin-list-dir.test.ts` drive this
8
module through the real boundary the way
9
`coder-plugin-mounts.test.ts` drives `file_stats` through `read_file`.
plugins/dir-stats/data/sample.txt added +3

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

1
alpha
2
beta
3
gamma
plugins/dir-stats/dir_stats.wasm added

Binary file. Nothing to show as text.

plugins/dir-stats/manifest.json added +72

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

1
{
2
  "manifest_version": 1,
3
  "name": "dir_stats",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "List one directory inside the plugin's declared read-only mount: entry names, kinds (file, dir, symlink), sizes, and modification times, bounded per listing. It cannot read file contents, write, or reach outside the mount. Use it when asked what a mounted directory contains.",
7
  "artifact": {
8
    "path": "dir_stats.wasm",
9
    "digest": "sha256:8156d9eba67c9467d6b9a2e562d000ec3a99046d8788f3ba42e1163f4adf4952"
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
        "mount_index": {
21
          "type": "integer",
22
          "description": "Which declared mount to list, by manifest order. Defaults to 0."
23
        },
24
        "path": {
25
          "type": "string",
26
          "description": "Directory path relative to the mount root; empty lists the root itself."
27
        }
28
      },
29
      "required": ["path"],
30
      "additionalProperties": false
31
    },
32
    "output": {
33
      "type": "object",
34
      "properties": {
35
        "ok": {
36
          "type": "object",
37
          "properties": {
38
            "entries": {
39
              "type": "array",
40
              "items": {
41
                "type": "object",
42
                "properties": {
43
                  "name": { "type": "string" },
44
                  "kind": { "type": "string" },
45
                  "size": { "type": "integer" },
46
                  "mtime_ms": { "type": "integer" }
47
                }
48
              }
49
            },
50
            "truncated": { "type": "boolean" }
51
          }
52
        },
53
        "refusal": {
54
          "type": "object",
55
          "properties": {
56
            "code": { "type": "string" },
57
            "reason": { "type": "string" }
58
          },
59
          "required": ["code", "reason"]
60
        }
61
      }
62
    }
63
  },
64
  "capabilities": {
65
    "mounts": [{ "path": "data", "readonly": true }],
66
    "hosts": [],
67
    "timeout_ms": 2000,
68
    "memory_max_mib": 64
69
  },
70
  "price_msats": null,
71
  "license": "Apache-2.0"
72
}
plugins/dir-stats/src/lib.rs added +28

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

1
//! Directory listing over a read-only mount, as a `packet-v0` guest plugin.
2
//!
3
//! The proof plugin for the host's second capability import,
4
//! `openagents.list_dir`: the manifest declares one read-only mount, the
5
//! guest asks for one listing by mount index and relative path, and the
6
//! host confines the path exactly as it confines reads — no absolute
7
//! paths, no `..` escapes, no symlinks — and bounds the entries per
8
//! listing. The escape tests in
9
//! `packages/openagents-cli/test/coder-plugin-list-dir.test.ts` drive this
10
//! module through the real boundary.
11
12
use openagents_pdk::{list_mounted_dir, plugin_entry, MountDirListing, Refusal};
13
use serde::Deserialize;
14
15
#[derive(Deserialize)]
16
struct Input {
17
    /// Which declared mount to list, by manifest order. Defaults to 0.
18
    #[serde(default)]
19
    mount_index: u32,
20
    /// Directory path relative to that mount's root; empty lists the root.
21
    path: String,
22
}
23
24
fn handle(input: Input) -> Result<MountDirListing, Refusal> {
25
    list_mounted_dir(input.mount_index, &input.path)
26
}
27
28
plugin_entry!(handle);
plugins/file-stats/file_stats.wasm modified

Binary file. Nothing to show as text.

plugins/file-stats/manifest.json modified +1 -1

@@ -6,7 +6,7 @@

6 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 7
  "artifact": {
8 8
    "path": "file_stats.wasm",
9
    "digest": "sha256:1f44edeadd163efd8a6ee73eb89725a94e93c1fbabb66791ce02bbc6bcb3be7d"
9
    "digest": "sha256:6479f4eac6b2b24d61ddc92ad3dd0f263f5ba5b9fe47e9bf8ab1f40cfad39006"
10 10
  },
11 11
  "abi": {
12 12
    "kind": "packet-v0",
plugins/foreign-sessions/Cargo.toml added +17

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

1
# The foreign-session discovery plugin: the scanner half of
2
# OpenAgentsInc/openagents.com#198, as a packet-v0 guest.
3
4
[package]
5
name = "foreign-sessions"
6
version = "0.1.0"
7
edition.workspace = true
8
license.workspace = true
9
description = "Guest plugin that discovers recent Claude Code and Codex CLI sessions from their read-only mounted state directories: metadata only, bounded, never a resume."
10
11
[lib]
12
crate-type = ["cdylib", "rlib"]
13
14
[dependencies]
15
openagents-pdk = { workspace = true }
16
serde = { workspace = true }
17
serde_json = { workspace = true }
plugins/foreign-sessions/README.md added +59

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

1
# foreign_sessions
2
3
Foreign coding-agent session discovery — the scanner half of
4
OpenAgentsInc/openagents.com#198, as a `packet-v0` WASM guest on the owned
5
PDK. Given read-only mounts over `~/.claude` (mount 0) and `~/.codex`
6
(mount 1), it reports recent session metadata: source, session id, working
7
directory, mtime, size, and record count. It discovers; it never resumes.
8
The resume/import half of #198 is later work that builds on this listing.
9
10
## What it scans
11
12
- **Claude Code** — `~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`.
13
  The working directory and session id come from the first records of each
14
  file (at most 20 lines inspected); the encoded project directory name is
15
  used as a cheap prefilter for `cwd_filter` before a file read is spent.
16
- **Codex CLI** — `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`. The
17
  working directory and id come from the `session_meta` first line.
18
- **Out of scope for this slice**: the Codex `~/.codex/state_*.sqlite`
19
  index and every other SQLite store. Reading SQLite from a
20
  `wasm32-unknown-unknown` guest is the known open risk (a wasm-sqlite
21
  spike), deferred deliberately; the rollout files carry enough for
22
  discovery. Cursor is also not scanned yet.
23
24
## Posture and bounds
25
26
Foreign state is untrusted input, and the plugin holds no authority of its
27
own — every access goes through the host's confined capability imports
28
(`openagents.read_file`, `openagents.list_dir`), which refuse absolute
29
paths, `..` escapes, and symlinks, and bound every read (1 MiB per file)
30
and listing (500 entries). On top of that the scanner bounds itself:
31
32
- at most 50 sessions reported (`limit` capped at 50, default 50)
33
- at most 200 file reads and 1500 directory listings per invocation
34
- at most 5000 candidate files held before sorting
35
- at most 20 leading JSONL lines inspected per file
36
- sessions older than `max_age_days` (default 30) are dropped; the sandbox
37
  has no clock, so the cutoff runs against `now_ms` when given and against
38
  the newest observed mtime otherwise
39
40
Failure is soft everywhere: a missing store is reported in
41
`missing_sources`, malformed and unreadable files are skipped and counted
42
in `skipped`, and a session file over the per-file read bound is still
43
reported from its listing metadata alone, marked `metadata_truncated`
44
(435 of the 1195 Claude session files on the development machine exceed
45
the bound, so this path is ordinary, not exceptional). `scan_truncated`
46
and `read_budget_exhausted` say when the picture may be partial.
47
48
## Try it
49
50
```
51
/plugin load plugins/foreign-sessions/manifest.json
52
```
53
54
then ask the coder to list recent sessions. The manifest's mounts are
55
declared as `~/.claude` and `~/.codex`; both must exist as directories on
56
the machine, or the load refuses (`mount_invalid`). Tests stage fixture
57
trees and point a copy of the manifest at them — see
58
`packages/openagents-cli/test/coder-plugin-foreign-sessions.test.ts` for
59
the boundary tests and `src/tests.rs` for the fake-host scanner tests.
plugins/foreign-sessions/foreign_sessions.wasm added

Binary file. Nothing to show as text.

plugins/foreign-sessions/manifest.json added +106

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

1
{
2
  "manifest_version": 1,
3
  "name": "foreign_sessions",
4
  "version": "0.1.0",
5
  "author": "OpenAgents",
6
  "description": "Discover recent Claude Code and Codex CLI sessions from their local state directories, mounted read-only: for each session its source, id, working directory, modification time, size, and record count. Metadata only — it never reads whole conversations back, never writes, and cannot resume anything. Use it when asked what foreign coding-agent sessions exist on this machine, optionally filtered to a working directory.",
7
  "artifact": {
8
    "path": "foreign_sessions.wasm",
9
    "digest": "sha256:164eed5637ccadd11c1df698e22436cdeeb963194b7a765dd845a745fcf3854d"
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
        "sources": {
21
          "type": "array",
22
          "items": { "type": "string", "enum": ["claude", "codex"] },
23
          "description": "Which stores to scan. Both when omitted."
24
        },
25
        "cwd_filter": {
26
          "type": "string",
27
          "description": "Only report sessions whose working directory contains this substring."
28
        },
29
        "max_age_days": {
30
          "type": "number",
31
          "description": "Only report sessions modified within this many days. Default 30."
32
        },
33
        "limit": {
34
          "type": "integer",
35
          "description": "Most sessions to report, newest first. Default 50, capped at 50."
36
        },
37
        "now_ms": {
38
          "type": "integer",
39
          "description": "Current time in milliseconds since the Unix epoch, for the age cutoff. The sandbox has no clock; when omitted, the newest session's own timestamp stands in."
40
        }
41
      },
42
      "required": [],
43
      "additionalProperties": false
44
    },
45
    "output": {
46
      "type": "object",
47
      "properties": {
48
        "ok": {
49
          "type": "object",
50
          "properties": {
51
            "sessions": {
52
              "type": "array",
53
              "items": {
54
                "type": "object",
55
                "properties": {
56
                  "source": { "type": "string" },
57
                  "session_id": { "type": "string" },
58
                  "path": { "type": "string" },
59
                  "cwd": { "type": "string" },
60
                  "project_dir": { "type": "string" },
61
                  "mtime_ms": { "type": "integer" },
62
                  "size_bytes": { "type": "integer" },
63
                  "record_count": { "type": "integer" },
64
                  "metadata_truncated": { "type": "boolean" }
65
                }
66
              }
67
            },
68
            "scanned_dirs": { "type": "integer" },
69
            "scanned_files": { "type": "integer" },
70
            "skipped": {
71
              "type": "object",
72
              "properties": {
73
                "malformed": { "type": "integer" },
74
                "unreadable": { "type": "integer" },
75
                "symlinked": { "type": "integer" }
76
              }
77
            },
78
            "oversized": { "type": "integer" },
79
            "missing_sources": { "type": "array", "items": { "type": "string" } },
80
            "scan_truncated": { "type": "boolean" },
81
            "read_budget_exhausted": { "type": "boolean" }
82
          }
83
        },
84
        "refusal": {
85
          "type": "object",
86
          "properties": {
87
            "code": { "type": "string" },
88
            "reason": { "type": "string" }
89
          },
90
          "required": ["code", "reason"]
91
        }
92
      }
93
    }
94
  },
95
  "capabilities": {
96
    "mounts": [
97
      { "path": "~/.claude", "readonly": true },
98
      { "path": "~/.codex", "readonly": true }
99
    ],
100
    "hosts": [],
101
    "timeout_ms": 10000,
102
    "memory_max_mib": 128
103
  },
104
  "price_msats": null,
105
  "license": "Apache-2.0"
106
}
plugins/foreign-sessions/src/lib.rs added +489

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

1
//! Foreign coding-agent session discovery, as a `packet-v0` guest plugin.
2
//!
3
//! The scanner half of OpenAgentsInc/openagents.com#198: given read-only
4
//! mounts over `~/.claude` (mount 0) and `~/.codex` (mount 1), report
5
//! recent session *metadata* — source, session id, working directory,
6
//! mtime, size, record count — and nothing else. Resuming a session is
7
//! deliberately not here; this plugin only says what exists.
8
//!
9
//! Foreign state is untrusted input, so the posture is the issue's:
10
//! read-only through the host's confined capability imports, bounded
11
//! everywhere (listing entries, per-file bytes, file reads per run,
12
//! directory listings per run, candidates, results), and fail-soft — a
13
//! missing directory contributes nothing, a malformed or unreadable file
14
//! is skipped and counted, a file over the host's per-file read bound is
15
//! reported from listing metadata alone and marked `metadata_truncated`.
16
//!
17
//! The guest has no clock on `wasm32-unknown-unknown`, so the age cutoff
18
//! runs against `now_ms` when the caller provides it, and otherwise
19
//! against the newest mtime the scan observed.
20
21
use openagents_pdk::{
22
    list_mounted_dir, plugin_entry, read_mounted_file, MountDirListing, Refusal, RefusalCode,
23
};
24
use serde::{Deserialize, Serialize};
25
26
/// Mount indices, fixed by the order `manifest.json` declares the mounts.
27
const CLAUDE_MOUNT: u32 = 0;
28
const CODEX_MOUNT: u32 = 1;
29
30
const DEFAULT_MAX_AGE_DAYS: f64 = 30.0;
31
const DEFAULT_LIMIT: usize = 50;
32
/// Hard cap on `limit`; asking for more is answered with this many.
33
const LIMIT_CAP: usize = 50;
34
/// How many leading JSONL lines may be inspected for session metadata.
35
const META_SCAN_LINES: usize = 20;
36
/// File reads per invocation, across both sources.
37
const MAX_FILE_READS: usize = 200;
38
/// Directory listings per invocation, across both sources.
39
const MAX_DIR_LISTS: usize = 1500;
40
/// Candidate files held before sorting; beyond this the scan reports itself
41
/// truncated rather than growing without bound.
42
const MAX_CANDIDATES: usize = 5000;
43
const MS_PER_DAY: f64 = 86_400_000.0;
44
45
#[derive(Deserialize)]
46
pub struct Input {
47
    /// Which stores to scan; both when absent.
48
    #[serde(default)]
49
    pub sources: Option<Vec<String>>,
50
    /// Substring the session's working directory must contain.
51
    #[serde(default)]
52
    pub cwd_filter: Option<String>,
53
    /// Sessions older than this are not reported. Default 30.
54
    #[serde(default)]
55
    pub max_age_days: Option<f64>,
56
    /// Most sessions to report, newest first. Default 50, capped at 50.
57
    #[serde(default)]
58
    pub limit: Option<usize>,
59
    /// Milliseconds since the Unix epoch, for the age cutoff. The sandbox
60
    /// has no clock; when absent, the newest observed mtime stands in.
61
    #[serde(default)]
62
    pub now_ms: Option<i64>,
63
}
64
65
#[derive(Debug, Serialize, PartialEq)]
66
pub struct Session {
67
    pub source: &'static str,
68
    pub session_id: String,
69
    /// Path relative to the source's mount root (`~/.claude` or `~/.codex`).
70
    pub path: String,
71
    #[serde(skip_serializing_if = "Option::is_none")]
72
    pub cwd: Option<String>,
73
    /// Claude only: the encoded project directory the session file sits in.
74
    #[serde(skip_serializing_if = "Option::is_none")]
75
    pub project_dir: Option<String>,
76
    pub mtime_ms: i64,
77
    pub size_bytes: u64,
78
    /// JSONL records in the file, when the file was small enough to read.
79
    #[serde(skip_serializing_if = "Option::is_none")]
80
    pub record_count: Option<usize>,
81
    /// True when the file exceeds the host's per-file read bound, so only
82
    /// the directory listing's metadata is known.
83
    #[serde(skip_serializing_if = "std::ops::Not::not")]
84
    pub metadata_truncated: bool,
85
}
86
87
#[derive(Debug, Default, Serialize, PartialEq, Eq)]
88
pub struct Skipped {
89
    /// Readable files whose leading records held no usable metadata.
90
    pub malformed: usize,
91
    /// Files the host refused to read for any reason but size.
92
    pub unreadable: usize,
93
    /// Symlinked entries, which the host would refuse to follow.
94
    pub symlinked: usize,
95
}
96
97
#[derive(Debug, Serialize)]
98
pub struct Output {
99
    pub sessions: Vec<Session>,
100
    pub scanned_dirs: usize,
101
    pub scanned_files: usize,
102
    pub skipped: Skipped,
103
    /// Files reported from listing metadata alone (over the read bound).
104
    pub oversized: usize,
105
    /// Sources whose store was not present under its mount.
106
    #[serde(skip_serializing_if = "Vec::is_empty")]
107
    pub missing_sources: Vec<&'static str>,
108
    /// True when any directory listing hit the host's entry bound, or the
109
    /// scan hit its own listing/candidate bounds; the picture may be partial.
110
    pub scan_truncated: bool,
111
    /// True when the per-invocation file-read budget ran out before every
112
    /// surviving candidate could be inspected.
113
    pub read_budget_exhausted: bool,
114
}
115
116
/// The two host capabilities the scanner uses, as a seam so the scan logic
117
/// runs under `cargo test` against a fake host as well as inside the WASM
118
/// sandbox against the real one.
119
pub trait Host {
120
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal>;
121
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal>;
122
}
123
124
struct RealHost;
125
126
impl Host for RealHost {
127
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
128
        list_mounted_dir(mount_index, path)
129
    }
130
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
131
        read_mounted_file(path)
132
    }
133
}
134
135
/// One file the listing pass found, before its bytes are inspected.
136
struct Candidate {
137
    source: &'static str,
138
    mount: u32,
139
    path: String,
140
    file_name: String,
141
    project_dir: Option<String>,
142
    mtime_ms: i64,
143
    size_bytes: u64,
144
}
145
146
/// Encode a string the way Claude Code encodes a cwd into a project
147
/// directory name: every character outside `[A-Za-z0-9]` becomes `-`.
148
pub fn dashed(text: &str) -> String {
149
    text.chars()
150
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
151
        .collect()
152
}
153
154
/// Claude session metadata from the file's leading records: the first
155
/// `cwd` and `sessionId` seen in the first [`META_SCAN_LINES`] lines, plus
156
/// the record count. `None` when no line yields a cwd.
157
pub fn claude_meta(bytes: &[u8]) -> Option<(String, Option<String>, usize)> {
158
    let text = String::from_utf8_lossy(bytes);
159
    let record_count = text.lines().count();
160
    let mut cwd = None;
161
    let mut session_id = None;
162
    for line in text.lines().take(META_SCAN_LINES) {
163
        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
164
            continue;
165
        };
166
        if cwd.is_none() {
167
            if let Some(dir) = value.get("cwd").and_then(|v| v.as_str()) {
168
                cwd = Some(dir.to_string());
169
            }
170
        }
171
        if session_id.is_none() {
172
            if let Some(id) = value.get("sessionId").and_then(|v| v.as_str()) {
173
                session_id = Some(id.to_string());
174
            }
175
        }
176
        if cwd.is_some() && session_id.is_some() {
177
            break;
178
        }
179
    }
180
    cwd.map(|dir| (dir, session_id, record_count))
181
}
182
183
/// Codex rollout metadata from the first line's `session_meta` record:
184
/// `(cwd, session id, record count)`. `None` when the first line is not a
185
/// well-formed `session_meta` with a `cwd`.
186
pub fn codex_meta(bytes: &[u8]) -> Option<(String, Option<String>, usize)> {
187
    let text = String::from_utf8_lossy(bytes);
188
    let record_count = text.lines().count();
189
    let first = text.lines().next()?;
190
    let value = serde_json::from_str::<serde_json::Value>(first).ok()?;
191
    if value.get("type").and_then(|v| v.as_str()) != Some("session_meta") {
192
        return None;
193
    }
194
    let payload = value.get("payload")?;
195
    let cwd = payload.get("cwd").and_then(|v| v.as_str())?.to_string();
196
    let id = payload
197
        .get("id")
198
        .and_then(|v| v.as_str())
199
        .map(str::to_string);
200
    Some((cwd, id, record_count))
201
}
202
203
/// A session file's stem: the name without its `.jsonl` suffix.
204
fn stem(name: &str) -> String {
205
    name.strip_suffix(".jsonl").unwrap_or(name).to_string()
206
}
207
208
/// The whole scan, over any [`Host`]. Total: every path returns an output.
209
pub fn scan(host: &dyn Host, input: &Input) -> Result<Output, Refusal> {
210
    let sources = match &input.sources {
211
        None => vec!["claude", "codex"],
212
        Some(named) => {
213
            let mut sources = Vec::new();
214
            for name in named {
215
                match name.as_str() {
216
                    "claude" => sources.push("claude"),
217
                    "codex" => sources.push("codex"),
218
                    other => {
219
                        return Err(Refusal::unsupported(format!(
220
                            "unknown source `{other}`; this scanner knows `claude` and `codex`"
221
                        )))
222
                    }
223
                }
224
            }
225
            sources
226
        }
227
    };
228
    let max_age_days = input
229
        .max_age_days
230
        .filter(|days| days.is_finite() && *days > 0.0)
231
        .unwrap_or(DEFAULT_MAX_AGE_DAYS);
232
    let limit = input.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, LIMIT_CAP);
233
234
    let mut out = Output {
235
        sessions: Vec::new(),
236
        scanned_dirs: 0,
237
        scanned_files: 0,
238
        skipped: Skipped::default(),
239
        oversized: 0,
240
        missing_sources: Vec::new(),
241
        scan_truncated: false,
242
        read_budget_exhausted: false,
243
    };
244
    let mut candidates: Vec<Candidate> = Vec::new();
245
    let mut dir_lists = 0usize;
246
247
    // A listing whose store directory is absent means the source is not on
248
    // this machine; any other listing failure also fails soft.
249
    let mut list = |out: &mut Output,
250
                    mount: u32,
251
                    path: &str|
252
     -> Option<MountDirListing> {
253
        if dir_lists >= MAX_DIR_LISTS {
254
            out.scan_truncated = true;
255
            return None;
256
        }
257
        dir_lists += 1;
258
        match host.list(mount, path) {
259
            Ok(listing) => {
260
                out.scanned_dirs += 1;
261
                if listing.truncated {
262
                    out.scan_truncated = true;
263
                }
264
                Some(listing)
265
            }
266
            Err(_) => None,
267
        }
268
    };
269
270
    let push = |out: &mut Output, candidates: &mut Vec<Candidate>, candidate: Candidate| {
271
        out.scanned_files += 1;
272
        if candidates.len() < MAX_CANDIDATES {
273
            candidates.push(candidate);
274
        } else {
275
            out.scan_truncated = true;
276
        }
277
    };
278
279
    for source in &sources {
280
        match *source {
281
            "claude" => {
282
                // ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl
283
                let Some(projects) = list(&mut out, CLAUDE_MOUNT, "projects") else {
284
                    out.missing_sources.push("claude");
285
                    continue;
286
                };
287
                for project in &projects.entries {
288
                    match project.kind.as_str() {
289
                        "dir" => {}
290
                        "symlink" => {
291
                            out.skipped.symlinked += 1;
292
                            continue;
293
                        }
294
                        _ => continue,
295
                    }
296
                    let dir_path = format!("projects/{}", project.name);
297
                    let Some(files) = list(&mut out, CLAUDE_MOUNT, &dir_path) else {
298
                        continue;
299
                    };
300
                    for file in &files.entries {
301
                        if file.kind == "symlink" {
302
                            out.skipped.symlinked += 1;
303
                            continue;
304
                        }
305
                        if file.kind != "file" || !file.name.ends_with(".jsonl") {
306
                            continue;
307
                        }
308
                        push(
309
                            &mut out,
310
                            &mut candidates,
311
                            Candidate {
312
                                source: "claude",
313
                                mount: CLAUDE_MOUNT,
314
                                path: format!("{dir_path}/{}", file.name),
315
                                file_name: file.name.clone(),
316
                                project_dir: Some(project.name.clone()),
317
                                mtime_ms: file.mtime_ms,
318
                                size_bytes: file.size,
319
                            },
320
                        );
321
                    }
322
                }
323
            }
324
            "codex" => {
325
                // ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. The
326
                // `state_*.sqlite` index beside it is out of scope for this
327
                // slice; see the plugin README.
328
                let Some(years) = list(&mut out, CODEX_MOUNT, "sessions") else {
329
                    out.missing_sources.push("codex");
330
                    continue;
331
                };
332
                for year in dirs_of(&years, &mut out.skipped) {
333
                    let year_path = format!("sessions/{year}");
334
                    let Some(months) = list(&mut out, CODEX_MOUNT, &year_path) else {
335
                        continue;
336
                    };
337
                    for month in dirs_of(&months, &mut out.skipped) {
338
                        let month_path = format!("{year_path}/{month}");
339
                        let Some(days) = list(&mut out, CODEX_MOUNT, &month_path) else {
340
                            continue;
341
                        };
342
                        for day in dirs_of(&days, &mut out.skipped) {
343
                            let day_path = format!("{month_path}/{day}");
344
                            let Some(files) = list(&mut out, CODEX_MOUNT, &day_path) else {
345
                                continue;
346
                            };
347
                            for file in &files.entries {
348
                                if file.kind == "symlink" {
349
                                    out.skipped.symlinked += 1;
350
                                    continue;
351
                                }
352
                                if file.kind != "file"
353
                                    || !file.name.starts_with("rollout-")
354
                                    || !file.name.ends_with(".jsonl")
355
                                {
356
                                    continue;
357
                                }
358
                                push(
359
                                    &mut out,
360
                                    &mut candidates,
361
                                    Candidate {
362
                                        source: "codex",
363
                                        mount: CODEX_MOUNT,
364
                                        path: format!("{day_path}/{}", file.name),
365
                                        file_name: file.name.clone(),
366
                                        project_dir: None,
367
                                        mtime_ms: file.mtime_ms,
368
                                        size_bytes: file.size,
369
                                    },
370
                                );
371
                            }
372
                        }
373
                    }
374
                }
375
            }
376
            _ => unreachable!("sources were validated above"),
377
        }
378
    }
379
380
    // The age cutoff: `now` is the caller's clock, or the newest thing seen.
381
    let now_ms = input
382
        .now_ms
383
        .or_else(|| candidates.iter().map(|c| c.mtime_ms).max())
384
        .unwrap_or(0);
385
    let cutoff_ms = now_ms - (max_age_days * MS_PER_DAY) as i64;
386
    candidates.retain(|c| c.mtime_ms >= cutoff_ms);
387
    candidates.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms).then(a.path.cmp(&b.path)));
388
389
    let dashed_filter = input.cwd_filter.as_deref().map(dashed);
390
    let mut reads = 0usize;
391
392
    for candidate in &candidates {
393
        if out.sessions.len() >= limit {
394
            break;
395
        }
396
        // Claude's project directory name encodes the cwd, so a filter can
397
        // rule a candidate out before spending a read on it.
398
        if let (Some(filter), Some(project_dir)) = (&dashed_filter, &candidate.project_dir) {
399
            if !dashed(project_dir).contains(filter.as_str()) {
400
                continue;
401
            }
402
        }
403
        if reads >= MAX_FILE_READS {
404
            out.read_budget_exhausted = true;
405
            break;
406
        }
407
        reads += 1;
408
        // `read` addresses the mounts in declaration order; the full
409
        // relative path (projects/... vs sessions/...) exists in exactly
410
        // one of them. `candidate.mount` records intent for the reader.
411
        let _ = candidate.mount;
412
        match host.read(&candidate.path) {
413
            Ok(bytes) => {
414
                let meta = match candidate.source {
415
                    "claude" => claude_meta(&bytes),
416
                    _ => codex_meta(&bytes),
417
                };
418
                let Some((cwd, session_id, record_count)) = meta else {
419
                    out.skipped.malformed += 1;
420
                    continue;
421
                };
422
                if let Some(filter) = input.cwd_filter.as_deref() {
423
                    if !cwd.contains(filter) && !dashed(&cwd).contains(&dashed(filter)) {
424
                        continue;
425
                    }
426
                }
427
                out.sessions.push(Session {
428
                    source: candidate.source,
429
                    session_id: session_id.unwrap_or_else(|| stem(&candidate.file_name)),
430
                    path: candidate.path.clone(),
431
                    cwd: Some(cwd),
432
                    project_dir: candidate.project_dir.clone(),
433
                    mtime_ms: candidate.mtime_ms,
434
                    size_bytes: candidate.size_bytes,
435
                    record_count: Some(record_count),
436
                    metadata_truncated: false,
437
                });
438
            }
439
            Err(refusal) if refusal.code == RefusalCode::FileTooLarge => {
440
                out.oversized += 1;
441
                // Only the listing's metadata is known. With a cwd filter, a
442
                // Claude candidate already passed the project-name prefilter;
443
                // a Codex candidate's cwd is unknowable here, so the filter
444
                // excludes it rather than guessing.
445
                if dashed_filter.is_some() && candidate.project_dir.is_none() {
446
                    continue;
447
                }
448
                out.sessions.push(Session {
449
                    source: candidate.source,
450
                    session_id: stem(&candidate.file_name),
451
                    path: candidate.path.clone(),
452
                    cwd: None,
453
                    project_dir: candidate.project_dir.clone(),
454
                    mtime_ms: candidate.mtime_ms,
455
                    size_bytes: candidate.size_bytes,
456
                    record_count: None,
457
                    metadata_truncated: true,
458
                });
459
            }
460
            Err(_) => {
461
                out.skipped.unreadable += 1;
462
            }
463
        }
464
    }
465
466
    Ok(out)
467
}
468
469
/// Directory names in a listing, counting symlinks as skipped.
470
fn dirs_of<'l>(listing: &'l MountDirListing, skipped: &mut Skipped) -> Vec<&'l str> {
471
    let mut dirs = Vec::new();
472
    for entry in &listing.entries {
473
        match entry.kind.as_str() {
474
            "dir" => dirs.push(entry.name.as_str()),
475
            "symlink" => skipped.symlinked += 1,
476
            _ => {}
477
        }
478
    }
479
    dirs
480
}
481
482
fn handle(input: Input) -> Result<Output, Refusal> {
483
    scan(&RealHost, &input)
484
}
485
486
plugin_entry!(handle);
487
488
#[cfg(test)]
489
mod tests;
plugins/foreign-sessions/src/tests.rs added +296

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

1
//! The scanner against a fake host: fixture trees with good, malformed,
2
//! oversized, and symlinked entries, exercised without a WASM runtime. The
3
//! same shapes run through the real boundary in
4
//! `packages/openagents-cli/test/coder-plugin-foreign-sessions.test.ts`.
5
6
use super::*;
7
use openagents_pdk::MountDirEntry;
8
use std::collections::BTreeMap;
9
10
/// A fake host over two in-memory mount trees. Directories are keyed by
11
/// `(mount, path)`; file bytes are keyed by mount-relative path the way
12
/// the real host's mount-order read loop would find them.
13
#[derive(Default)]
14
struct FakeHost {
15
    dirs: BTreeMap<(u32, String), MountDirListing>,
16
    files: BTreeMap<String, Vec<u8>>,
17
    /// Paths the read import answers with `file_too_large`.
18
    oversized: Vec<String>,
19
    /// Paths the read import answers with `file_unreadable`.
20
    unreadable: Vec<String>,
21
}
22
23
impl FakeHost {
24
    fn dir(&mut self, mount: u32, path: &str, entries: Vec<MountDirEntry>) {
25
        self.dirs.insert(
26
            (mount, path.to_string()),
27
            MountDirListing { entries, truncated: false },
28
        );
29
    }
30
    fn file(&mut self, path: &str, bytes: &str) {
31
        self.files.insert(path.to_string(), bytes.as_bytes().to_vec());
32
    }
33
}
34
35
impl Host for FakeHost {
36
    fn list(&self, mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
37
        self.dirs
38
            .get(&(mount_index, path.to_string()))
39
            .cloned()
40
            .ok_or_else(|| {
41
                Refusal::new(RefusalCode::FileUnreadable, "the mount has no such directory")
42
            })
43
    }
44
    fn read(&self, path: &str) -> Result<Vec<u8>, Refusal> {
45
        if self.oversized.iter().any(|p| p == path) {
46
            return Err(Refusal::new(RefusalCode::FileTooLarge, "over the bound"));
47
        }
48
        if self.unreadable.iter().any(|p| p == path) {
49
            return Err(Refusal::new(RefusalCode::FileUnreadable, "io failure"));
50
        }
51
        self.files
52
            .get(path)
53
            .cloned()
54
            .ok_or_else(|| Refusal::new(RefusalCode::MountDenied, "no declared mount contains the path"))
55
    }
56
}
57
58
fn entry(name: &str, kind: &str, size: u64, mtime_ms: i64) -> MountDirEntry {
59
    MountDirEntry { name: name.to_string(), kind: kind.to_string(), size, mtime_ms }
60
}
61
62
fn claude_line(cwd: &str, session_id: &str) -> String {
63
    format!(
64
        r#"{{"type":"user","cwd":"{cwd}","sessionId":"{session_id}","message":{{"role":"user","content":"hi"}}}}"#
65
    )
66
}
67
68
fn codex_line(cwd: &str, id: &str) -> String {
69
    format!(
70
        r#"{{"timestamp":"2026-08-20T10:00:00.000Z","type":"session_meta","payload":{{"id":"{id}","cwd":"{cwd}"}}}}"#
71
    )
72
}
73
74
const DAY: i64 = 86_400_000;
75
const NOW: i64 = 1_756_000_000_000;
76
77
/// A host with one recent Claude session, one recent Codex session, and one
78
/// stale Claude session (90 days old).
79
fn seeded() -> FakeHost {
80
    let mut host = FakeHost::default();
81
    host.dir(
82
        CLAUDE_MOUNT,
83
        "projects",
84
        vec![entry("-Users-ada-work-proj", "dir", 0, NOW)],
85
    );
86
    host.dir(
87
        CLAUDE_MOUNT,
88
        "projects/-Users-ada-work-proj",
89
        vec![
90
            entry("aaa.jsonl", "file", 100, NOW - DAY),
91
            entry("old.jsonl", "file", 90, NOW - 90 * DAY),
92
        ],
93
    );
94
    host.file(
95
        "projects/-Users-ada-work-proj/aaa.jsonl",
96
        &format!("{}\n{}\n", claude_line("/Users/ada/work/proj", "aaa"), claude_line("/Users/ada/work/proj", "aaa")),
97
    );
98
    host.file(
99
        "projects/-Users-ada-work-proj/old.jsonl",
100
        &claude_line("/Users/ada/work/proj", "old"),
101
    );
102
    host.dir(CODEX_MOUNT, "sessions", vec![entry("2026", "dir", 0, NOW)]);
103
    host.dir(CODEX_MOUNT, "sessions/2026", vec![entry("08", "dir", 0, NOW)]);
104
    host.dir(CODEX_MOUNT, "sessions/2026/08", vec![entry("20", "dir", 0, NOW)]);
105
    host.dir(
106
        CODEX_MOUNT,
107
        "sessions/2026/08/20",
108
        vec![entry("rollout-2026-08-20T10-00-00-bbb.jsonl", "file", 200, NOW - 2 * DAY)],
109
    );
110
    host.file(
111
        "sessions/2026/08/20/rollout-2026-08-20T10-00-00-bbb.jsonl",
112
        &format!("{}\n", codex_line("/Users/ada/work/other", "bbb")),
113
    );
114
    host
115
}
116
117
fn input() -> Input {
118
    Input { sources: None, cwd_filter: None, max_age_days: None, limit: None, now_ms: Some(NOW) }
119
}
120
121
#[test]
122
fn recent_sessions_from_both_sources_come_back_newest_first() {
123
    let out = scan(&seeded(), &input()).unwrap();
124
    assert_eq!(
125
        out.sessions.iter().map(|s| s.session_id.as_str()).collect::<Vec<_>>(),
126
        vec!["aaa", "bbb"],
127
    );
128
    let claude = &out.sessions[0];
129
    assert_eq!(claude.source, "claude");
130
    assert_eq!(claude.cwd.as_deref(), Some("/Users/ada/work/proj"));
131
    assert_eq!(claude.record_count, Some(2));
132
    assert_eq!(claude.project_dir.as_deref(), Some("-Users-ada-work-proj"));
133
    assert!(!claude.metadata_truncated);
134
    let codex = &out.sessions[1];
135
    assert_eq!(codex.source, "codex");
136
    assert_eq!(codex.cwd.as_deref(), Some("/Users/ada/work/other"));
137
    assert_eq!(out.skipped, Skipped::default());
138
}
139
140
#[test]
141
fn the_age_cutoff_drops_stale_sessions() {
142
    let out = scan(&seeded(), &input()).unwrap();
143
    assert!(out.sessions.iter().all(|s| s.session_id != "old"));
144
    let out = scan(&seeded(), &Input { max_age_days: Some(365.0), ..input() }).unwrap();
145
    assert!(out.sessions.iter().any(|s| s.session_id == "old"));
146
}
147
148
#[test]
149
fn without_a_clock_the_newest_mtime_stands_in_for_now() {
150
    let out = scan(&seeded(), &Input { now_ms: None, ..input() }).unwrap();
151
    // Newest is aaa at NOW - DAY; old at NOW - 90*DAY is outside 30 days of it.
152
    assert_eq!(out.sessions.len(), 2);
153
}
154
155
#[test]
156
fn a_cwd_filter_narrows_by_working_directory() {
157
    let out = scan(&seeded(), &Input { cwd_filter: Some("work/proj".into()), ..input() }).unwrap();
158
    assert_eq!(out.sessions.len(), 1);
159
    assert_eq!(out.sessions[0].session_id, "aaa");
160
}
161
162
#[test]
163
fn an_unknown_source_is_refused_not_guessed() {
164
    let refusal =
165
        scan(&seeded(), &Input { sources: Some(vec!["cursor".into()]), ..input() }).unwrap_err();
166
    assert_eq!(refusal.code, RefusalCode::Unsupported);
167
}
168
169
#[test]
170
fn a_missing_store_is_reported_not_fatal() {
171
    let mut host = seeded();
172
    host.dirs.remove(&(CODEX_MOUNT, "sessions".to_string()));
173
    let out = scan(&host, &input()).unwrap();
174
    assert_eq!(out.missing_sources, vec!["codex"]);
175
    assert_eq!(out.sessions.len(), 1);
176
}
177
178
#[test]
179
fn malformed_files_are_skipped_and_counted() {
180
    let mut host = seeded();
181
    host.dir(
182
        CLAUDE_MOUNT,
183
        "projects/-Users-ada-work-proj",
184
        vec![
185
            entry("aaa.jsonl", "file", 100, NOW - DAY),
186
            entry("bad.jsonl", "file", 50, NOW - DAY),
187
        ],
188
    );
189
    host.file("projects/-Users-ada-work-proj/bad.jsonl", "not json at all\n{}\n");
190
    let out = scan(&host, &input()).unwrap();
191
    assert_eq!(out.skipped.malformed, 1);
192
    assert!(out.sessions.iter().any(|s| s.session_id == "aaa"));
193
}
194
195
#[test]
196
fn an_oversized_file_keeps_listing_metadata_and_is_marked_truncated() {
197
    let mut host = seeded();
198
    host.dir(
199
        CLAUDE_MOUNT,
200
        "projects/-Users-ada-work-proj",
201
        vec![entry("huge.jsonl", "file", 5_000_000, NOW)],
202
    );
203
    host.oversized.push("projects/-Users-ada-work-proj/huge.jsonl".to_string());
204
    let out = scan(&host, &input()).unwrap();
205
    let huge = out.sessions.iter().find(|s| s.session_id == "huge").unwrap();
206
    assert!(huge.metadata_truncated);
207
    assert_eq!(huge.cwd, None);
208
    assert_eq!(huge.record_count, None);
209
    assert_eq!(huge.size_bytes, 5_000_000);
210
    assert_eq!(out.oversized, 1);
211
}
212
213
#[test]
214
fn symlinked_entries_are_skipped_and_counted() {
215
    let mut host = seeded();
216
    host.dir(
217
        CLAUDE_MOUNT,
218
        "projects/-Users-ada-work-proj",
219
        vec![
220
            entry("aaa.jsonl", "file", 100, NOW - DAY),
221
            entry("sneaky.jsonl", "symlink", 0, NOW),
222
        ],
223
    );
224
    let out = scan(&host, &input()).unwrap();
225
    assert_eq!(out.skipped.symlinked, 1);
226
    assert!(out.sessions.iter().all(|s| s.session_id != "sneaky"));
227
}
228
229
#[test]
230
fn unreadable_files_are_skipped_and_counted() {
231
    let mut host = seeded();
232
    host.unreadable.push("projects/-Users-ada-work-proj/aaa.jsonl".to_string());
233
    let out = scan(&host, &input()).unwrap();
234
    assert_eq!(out.skipped.unreadable, 1);
235
    assert!(out.sessions.iter().all(|s| s.session_id != "aaa"));
236
}
237
238
#[test]
239
fn the_limit_caps_results_and_never_exceeds_fifty() {
240
    let mut host = seeded();
241
    let entries: Vec<MountDirEntry> = (0..80)
242
        .map(|i| entry(&format!("s{i:02}.jsonl"), "file", 10, NOW - i * 1000))
243
        .collect();
244
    host.dir(CLAUDE_MOUNT, "projects/-Users-ada-work-proj", entries);
245
    for i in 0..80 {
246
        host.file(
247
            &format!("projects/-Users-ada-work-proj/s{i:02}.jsonl"),
248
            &claude_line("/Users/ada/work/proj", &format!("s{i:02}")),
249
        );
250
    }
251
    let out = scan(&host, &Input { limit: Some(3), ..input() }).unwrap();
252
    assert_eq!(out.sessions.len(), 3);
253
    assert_eq!(out.sessions[0].session_id, "s00");
254
    let out = scan(&host, &Input { limit: Some(10_000), ..input() }).unwrap();
255
    assert_eq!(out.sessions.len(), 50);
256
}
257
258
#[test]
259
fn a_truncated_listing_marks_the_scan_truncated() {
260
    let mut host = seeded();
261
    host.dirs
262
        .get_mut(&(CLAUDE_MOUNT, "projects".to_string()))
263
        .unwrap()
264
        .truncated = true;
265
    let out = scan(&host, &input()).unwrap();
266
    assert!(out.scan_truncated);
267
}
268
269
#[test]
270
fn dashing_matches_claudes_project_directory_encoding() {
271
    assert_eq!(dashed("/Users/ada/work/openagents.com"), "-Users-ada-work-openagents-com");
272
    assert_eq!(dashed("openagents.com"), "openagents-com");
273
}
274
275
#[test]
276
fn claude_meta_scans_past_leading_records_without_a_cwd() {
277
    let bytes = format!(
278
        "{}\n{}\n{}\n",
279
        r#"{"type":"mode","mode":"normal","sessionId":"abc"}"#,
280
        r#"{"type":"file-history-snapshot","messageId":"m1"}"#,
281
        claude_line("/Users/ada/work/proj", "abc"),
282
    );
283
    let (cwd, session_id, records) = claude_meta(bytes.as_bytes()).unwrap();
284
    assert_eq!(cwd, "/Users/ada/work/proj");
285
    assert_eq!(session_id.as_deref(), Some("abc"));
286
    assert_eq!(records, 3);
287
}
288
289
#[test]
290
fn codex_meta_requires_a_session_meta_first_line() {
291
    assert!(codex_meta(br#"{"type":"other"}"#).is_none());
292
    assert!(codex_meta(b"garbage").is_none());
293
    let (cwd, id, _) = codex_meta(codex_line("/tmp/x", "id1").as_bytes()).unwrap();
294
    assert_eq!(cwd, "/tmp/x");
295
    assert_eq!(id.as_deref(), Some("id1"));
296
}
plugins/pdk/src/lib.rs modified +108 -13

@@ -42,12 +42,13 @@

42 42
//!
43 43
//! ## Host capabilities
44 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.
45
//! [`read_mounted_file`] and [`list_mounted_dir`] are the host imports:
46
//! available only when the plugin's manifest declares read-only mounts,
47
//! and answered by the host with either the payload or a typed refusal
48
//! (`mount_denied`, `file_unreadable`, `file_too_large`). A plugin that
49
//! never calls them links no imports at all — the compiler strips the
50
//! unused externs — so a pure-compute plugin still passes the host's
51
//! empty-import inspection.
51 52
52 53
use serde::de::DeserializeOwned;
53 54
use serde::Serialize;

@@ -101,6 +102,8 @@ impl RefusalCode {

101 102
102 103
    /// The code for a host-authored refusal packet. Unknown codes fold to
103 104
    /// [`RefusalCode::Internal`]; the caller keeps the raw text in the reason.
105
    /// Reached only from the wasm-side import decoding (and its tests).
106
    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
104 107
    fn parse(code: &str) -> Option<Self> {
105 108
        match code {
106 109
            "bad_packet" => Some(RefusalCode::BadPacket),

@@ -187,8 +190,50 @@ pub fn read_mounted_file(path: &str) -> Result<Vec<u8>, Refusal> {

187 190
    imp::read_mounted_file(path)
188 191
}
189 192
190
/// Parse a host `read_file` answer packet: one status byte, then either
191
/// the file bytes (0) or a `{"code", "reason"}` refusal (1).
193
/// One entry of a mounted directory listing, as the host reports it.
194
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
195
pub struct MountDirEntry {
196
    pub name: String,
197
    /// `"file"`, `"dir"`, `"symlink"`, or `"other"`. Symlinks are reported
198
    /// but never followed; a read through one is refused by the host.
199
    pub kind: String,
200
    pub size: u64,
201
    /// Modification time in milliseconds since the Unix epoch.
202
    pub mtime_ms: i64,
203
}
204
205
/// A mounted directory listing: at most the host's entry bound, sorted by
206
/// name, with `truncated` set when the directory held more.
207
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
208
pub struct MountDirListing {
209
    pub entries: Vec<MountDirEntry>,
210
    pub truncated: bool,
211
}
212
213
/// List a directory inside one of the manifest's declared read-only mounts.
214
///
215
/// `mount_index` names the mount by its position in the manifest's
216
/// `capabilities.mounts` array — explicit, because a scanner over several
217
/// mounts must never wonder which root answered a listing. The path is
218
/// relative to that mount's root (`""` or `"."` lists the root itself);
219
/// the host confines it exactly as it confines reads (no absolute paths,
220
/// no `..` escapes, no symlinks) and bounds the entries per listing. On a
221
/// target other than `wasm32-unknown-unknown` the import does not exist
222
/// and this returns `unsupported`.
223
pub fn list_mounted_dir(mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
224
    imp::list_mounted_dir(mount_index, path)
225
}
226
227
/// Decode a host listing packet's payload into [`MountDirListing`].
228
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
229
fn parse_listing(bytes: &[u8]) -> Result<MountDirListing, Refusal> {
230
    serde_json::from_slice(bytes)
231
        .map_err(|err| Refusal::internal(format!("the host's listing packet does not decode: {err}")))
232
}
233
234
/// Parse a host answer packet: one status byte, then either the payload
235
/// bytes (0) or a `{"code", "reason"}` refusal (1).
236
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
192 237
fn parse_host_packet(packet: &[u8]) -> Result<Vec<u8>, Refusal> {
193 238
    #[derive(serde::Deserialize)]
194 239
    struct RawRefusal {

@@ -210,7 +255,7 @@ fn parse_host_packet(packet: &[u8]) -> Result<Vec<u8>, Refusal> {

210 255
211 256
#[cfg(target_arch = "wasm32")]
212 257
mod imp {
213
    use super::{parse_host_packet, Refusal};
258
    use super::{parse_host_packet, parse_listing, MountDirListing, Refusal};
214 259
215 260
    #[link(wasm_import_module = "openagents")]
216 261
    extern "C" {

@@ -218,29 +263,51 @@ mod imp {

218 263
        /// of an answer packet the host wrote into guest memory through
219 264
        /// `packet_alloc`. Present only when the manifest declares mounts.
220 265
        fn read_file(path_ptr: *const u8, path_len: u32) -> u64;
266
        /// Host capability import: list one directory of the mount named by
267
        /// `mount_index`. Same answer-packet shape as `read_file`; the
268
        /// payload is the JSON encoding of a listing. Present only when the
269
        /// manifest declares mounts.
270
        fn list_dir(mount_index: u32, path_ptr: *const u8, path_len: u32) -> u64;
221 271
    }
222 272
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) };
273
    /// Unpack a host answer word into the packet slice it points at.
274
    unsafe fn host_packet<'a>(packed: u64) -> Result<&'a [u8], Refusal> {
225 275
        let ptr = (packed >> 32) as u32 as usize as *const u8;
226 276
        let len = (packed & 0xffff_ffff) as usize;
227 277
        if ptr.is_null() {
228 278
            return Err(Refusal::internal("the host answered with a null packet"));
229 279
        }
230
        let packet = unsafe { core::slice::from_raw_parts(ptr, len) };
280
        Ok(core::slice::from_raw_parts(ptr, len))
281
    }
282
283
    pub fn read_mounted_file(path: &str) -> Result<Vec<u8>, Refusal> {
284
        let packed = unsafe { read_file(path.as_ptr(), path.len() as u32) };
285
        let packet = unsafe { host_packet(packed) }?;
231 286
        parse_host_packet(packet)
232 287
    }
288
289
    pub fn list_mounted_dir(mount_index: u32, path: &str) -> Result<MountDirListing, Refusal> {
290
        let packed = unsafe { list_dir(mount_index, path.as_ptr(), path.len() as u32) };
291
        let packet = unsafe { host_packet(packed) }?;
292
        parse_listing(&parse_host_packet(packet)?)
293
    }
233 294
}
234 295
235 296
#[cfg(not(target_arch = "wasm32"))]
236 297
mod imp {
237
    use super::Refusal;
298
    use super::{MountDirListing, Refusal};
238 299
239 300
    pub fn read_mounted_file(_path: &str) -> Result<Vec<u8>, Refusal> {
240 301
        Err(Refusal::unsupported(
241 302
            "read_mounted_file is a host capability import; it exists only inside the WASM host",
242 303
        ))
243 304
    }
305
306
    pub fn list_mounted_dir(_mount_index: u32, _path: &str) -> Result<MountDirListing, Refusal> {
307
        Err(Refusal::unsupported(
308
            "list_mounted_dir is a host capability import; it exists only inside the WASM host",
309
        ))
310
    }
244 311
}
245 312
246 313
/// The pointer plumbing behind [`plugin_entry!`]. Hidden, not private, so

@@ -370,4 +437,32 @@ mod tests {

370 437
        let refusal = read_mounted_file("anything.txt").unwrap_err();
371 438
        assert_eq!(refusal.code, RefusalCode::Unsupported);
372 439
    }
440
441
    #[test]
442
    fn off_wasm_the_listing_import_is_an_unsupported_refusal() {
443
        let refusal = list_mounted_dir(0, "anywhere").unwrap_err();
444
        assert_eq!(refusal.code, RefusalCode::Unsupported);
445
    }
446
447
    #[test]
448
    fn a_listing_payload_decodes_into_typed_entries() {
449
        let payload = br#"{"entries":[{"name":"a.jsonl","kind":"file","size":12,"mtime_ms":1000}],"truncated":true}"#;
450
        let listing = parse_listing(payload).unwrap();
451
        assert!(listing.truncated);
452
        assert_eq!(
453
            listing.entries,
454
            vec![MountDirEntry {
455
                name: "a.jsonl".to_string(),
456
                kind: "file".to_string(),
457
                size: 12,
458
                mtime_ms: 1000,
459
            }]
460
        );
461
    }
462
463
    #[test]
464
    fn an_undecodable_listing_payload_is_an_internal_refusal() {
465
        let refusal = parse_listing(b"not json").unwrap_err();
466
        assert_eq!(refusal.code, RefusalCode::Internal);
467
    }
373 468
}
plugins/word-stats/manifest.json modified +1 -1

@@ -6,7 +6,7 @@

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:340fc35fec83dfb17d48c12544fbe5d12de5d5689d8dd1ea4745c4dc55a3c33f"
9
    "digest": "sha256:30428d45ace3993d0503abaf4e33a406d251e106a44f4687acd6a6f6438bea26"
10 10
  },
11 11
  "abi": {
12 12
    "kind": "packet-v0",
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