Give the coder one capability tool, not a catalog

1b07ec31dec5 · AtlantisPleb · · parent bb1d07dbdc3b

Give the coder one capability tool, not a catalog

The standing prompt grows by exactly one tool. Installed plugins stay
digest-pinned in the local catalog and cost zero prompt bytes until a
search selects one, so a machine with twenty plugins declares the same
tool set as a machine with none — pinned by a test, because that is
the property that keeps a growing catalog from taxing every turn.

Approval keys to the digest and the declared capabilities rather than
to a name: a capability declaring no mounts and no hosts may auto-run,
read-only mounts ask once and cache the answer against the digest and
the capabilities it was given for, and hosts or writable mounts ask
every time. With no approver configured, anything impure is refused
rather than run. Each tier has a test.

Selection is honest about what it can do. The workspace forbids
keyword routing for capability selection, and no embedding path exists
in this package, so the tool returns the typed catalog candidates and
their descriptions and lets the model choose, rather than dressing a
substring match up as semantics. Invocation stays exact-name. A search
that finds nothing records a capability gap for the registry's gap
loop to consume.

Built by a Devin child through the openagents coder's delegate tool;
810 CLI tests green.

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • added packages/openagents-cli/src/coder-capability.ts
  • modified packages/openagents-cli/src/coder-plugin-engine.ts
  • modified packages/openagents-cli/src/coder-plugins.ts
  • added packages/openagents-cli/test/coder-capability.test.ts

Diff

7 files changed, +546 -29

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2469,
7
    "filesScanned": 2470,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

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

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:d6a28da451ce9ef6f5cf228f613eb7a4a8e932087110a750f3bb6d198737304c",
4
  "sourceDigest": "sha256:6b3d3d24bcf4c883ce8e7c533b6d8f547bf5aee845e8631fb086f84af9bc4b16",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (64 tracked test files)"
1879
          "ref": "packages/openagents-cli (65 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +42 -24

@@ -95,8 +95,14 @@ import {

95 95
  loadPluginFromManifest,
96 96
  pluginIdentity,
97 97
  pluginTool,
98
  PluginApproval,
98 99
  type LoadedPlugin,
99 100
} from "./coder-plugins.js";
101
import {
102
  capabilityTool,
103
  defaultCapabilityGapRecorder,
104
  discoverPluginCatalog,
105
} from "./coder-capability.js";
100 106
import { runForeignResume } from "./coder-foreign-resume.js";
101 107
import { existsSync } from "node:fs";
102 108
import { spawnSync } from "node:child_process";

@@ -2320,13 +2326,47 @@ const coderCommand = Command.make(

2320 2326
      // plugin materializes one tool for the rest of this session and nothing
2321 2327
      // outlives the process.
2322 2328
      const plugins: LoadedPlugin[] = [];
2323
      const declareTools = () => {
2329
      const catalog = discoverPluginCatalog(fileURLToPath(import.meta.url));
2330
      const onSelect = (outcome: LoadedPlugin, manifestFile: string) => {
2331
        // Reloading a name replaces it: a demo iterates on one plugin, and
2332
        // two tools with one name would be a declaration the model cannot
2333
        // tell apart.
2334
        const at = plugins.findIndex((held) => held.manifest.name === outcome.manifest.name);
2335
        if (at >= 0) plugins.splice(at, 1);
2336
        plugins.push(outcome);
2337
        declareTools();
2338
        const identity = pluginIdentity(outcome);
2339
        session.recordPluginEvent({
2340
          message: describeLoad(outcome),
2341
          event: "plugin_loaded",
2342
          plugin: {
2343
            name: identity.name,
2344
            version: identity.version,
2345
            artifactDigest: identity.artifactDigest,
2346
            bytes: identity.bytes,
2347
            abi: identity.abi,
2348
            timeoutMs: identity.timeoutMs,
2349
            capabilities: identity.capabilities,
2350
            manifestPath: manifestFile,
2351
            toolName: identity.toolName,
2352
          },
2353
        });
2354
      };
2355
      const capability = capabilityTool({
2356
        catalog,
2357
        approval: new PluginApproval(),
2358
        recordGap: defaultCapabilityGapRecorder(),
2359
        onSelect,
2360
      });
2361
      let declareTools: () => void;
2362
      declareTools = () => {
2324 2363
        const active = skills.active();
2325 2364
        const tools = [
2326 2365
          shellTool(process.cwd()),
2327 2366
          ...(active.length === 0 ? [] : [skillTool(active)]),
2328 2367
          openagentsTool(),
2329 2368
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
2369
          capability,
2330 2370
          ...plugins.map((plugin) => pluginTool(plugin)),
2331 2371
        ];
2332 2372
        source.useTools?.(tools);

@@ -2352,29 +2392,7 @@ const coderCommand = Command.make(

2352 2392
          });
2353 2393
          return described;
2354 2394
        }
2355
        // Reloading a name replaces it: a demo iterates on one plugin, and
2356
        // two tools with one name would be a declaration the model cannot
2357
        // tell apart.
2358
        const at = plugins.findIndex((held) => held.manifest.name === outcome.manifest.name);
2359
        if (at >= 0) plugins.splice(at, 1);
2360
        plugins.push(outcome);
2361
        declareTools();
2362
        const identity = pluginIdentity(outcome);
2363
        session.recordPluginEvent({
2364
          message: described,
2365
          event: "plugin_loaded",
2366
          plugin: {
2367
            name: identity.name,
2368
            version: identity.version,
2369
            artifactDigest: identity.artifactDigest,
2370
            bytes: identity.bytes,
2371
            abi: identity.abi,
2372
            timeoutMs: identity.timeoutMs,
2373
            capabilities: identity.capabilities,
2374
            manifestPath: manifestFile,
2375
            toolName: identity.toolName,
2376
          },
2377
        });
2395
        onSelect(outcome, manifestFile);
2378 2396
        return described;
2379 2397
      };
2380 2398
packages/openagents-cli/src/coder-capability.ts added +217

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

1
/**
2
 * The standing `capability` tool and the local plugin catalog it searches.
3
 *
4
 * A session starts with exactly one `capability` tool. The model searches the
5
 * catalog of installed, digest-pinned plugins by describing what it needs. This
6
 * package has no embedding path, so the tool does not rank or filter by
7
 * keyword; it returns the full catalog and lets the model pick by exact name.
8
 *
9
 * When the model calls `capability` with the exact catalog name, the plugin is
10
 * approved, loaded, and added to the session tools. Its own schema then becomes
11
 * available, so every later call uses the exact catalog name as the tool name.
12
 */
13
14
import { appendFile, mkdir } from "node:fs/promises";
15
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
16
import { homedir } from "node:os";
17
import { dirname, join } from "node:path";
18
import { fileURLToPath } from "node:url";
19
20
import type { CoderTool } from "./coder-tools.js";
21
import {
22
  describeLoad,
23
  isRefusal,
24
  loadPluginFromManifest,
25
  type LoadedPlugin,
26
  type PluginApproval,
27
  type PluginManifest,
28
  type PluginRefusal,
29
  validateManifest,
30
} from "./coder-plugins.js";
31
32
/** One installed, digest-pinned plugin as the catalog sees it. */
33
export interface PluginCatalogEntry {
34
  readonly name: string;
35
  readonly version: string;
36
  readonly description: string;
37
  readonly manifestPath: string;
38
  readonly artifact: { readonly path: string; readonly digest: string };
39
  readonly capabilities: PluginManifest["capabilities"];
40
}
41
42
/** A recorded request for a capability the local catalog could not satisfy. */
43
export interface CapabilityGap {
44
  /** Epoch milliseconds. */
45
  readonly requestedAt: number;
46
  readonly query?: string | undefined;
47
  readonly requestedName?: string | undefined;
48
}
49
50
export interface CapabilityOptions {
51
  readonly catalog: ReadonlyArray<PluginCatalogEntry>;
52
  readonly approval: PluginApproval;
53
  /**
54
   * Record a no-match for later registry loop consumption. Called before the
55
   * tool returns, so the record is not lost if the caller then fails.
56
   */
57
  readonly recordGap: (gap: CapabilityGap) => void | Promise<void>;
58
  /**
59
   * Called when a plugin is approved and loaded so the session can declare its
60
   * dedicated tool. The manifest path is passed along for provenance records.
61
   */
62
  readonly onSelect: (plugin: LoadedPlugin, manifestPath: string) => void;
63
  /** Load a manifest into a verified, digest-pinned plugin. Defaults to the host loader. */
64
  readonly load?: (manifestPath: string) => LoadedPlugin | PluginRefusal;
65
}
66
67
/**
68
 * Walk upward from the caller's location until a `plugins/` directory is found,
69
 * then read every child `manifest.json` inside it. Invalid manifests are ignored;
70
 * this is discovery, not verification, and verification happens at load time.
71
 */
72
export function discoverPluginCatalog(from = import.meta.url): ReadonlyArray<PluginCatalogEntry> {
73
  const start = from.startsWith("file:") ? fileURLToPath(from) : from;
74
  let here = start;
75
  while (true) {
76
    const candidate = join(here, "plugins");
77
    if (existsSync(candidate) && statSync(candidate).isDirectory()) {
78
      const found: PluginCatalogEntry[] = [];
79
      for (const dir of readdirSync(candidate)) {
80
        const manifestPath = join(candidate, dir, "manifest.json");
81
        if (!existsSync(manifestPath)) continue;
82
        let raw: string;
83
        try {
84
          raw = readFileSync(manifestPath, "utf8");
85
        } catch {
86
          continue;
87
        }
88
        let parsed: unknown;
89
        try {
90
          parsed = JSON.parse(raw);
91
        } catch {
92
          continue;
93
        }
94
        const manifest = validateManifest(parsed);
95
        if (isRefusal(manifest)) continue;
96
        found.push({
97
          name: manifest.name,
98
          version: manifest.version,
99
          description: manifest.description,
100
          manifestPath,
101
          artifact: manifest.artifact,
102
          capabilities: manifest.capabilities,
103
        });
104
      }
105
      return found;
106
    }
107
    const parent = dirname(here);
108
    if (parent === here) break;
109
    here = parent;
110
  }
111
  return [];
112
}
113
114
/**
115
 * A default local record of capability gaps, for later registry loop consumption.
116
 *
117
 * Writes one JSON object per line to `~/.openagents/capability-gaps.jsonl`.
118
 * The recorder only writes; it does not read back. Callers that need to act on
119
 * the record can read the file themselves.
120
 */
121
export function defaultCapabilityGapRecorder(
122
  path = join(homedir(), ".openagents", "capability-gaps.jsonl"),
123
): (gap: CapabilityGap) => Promise<void> {
124
  return async (gap) => {
125
    await mkdir(dirname(path), { recursive: true });
126
    await appendFile(path, `${JSON.stringify(gap)}\n`, "utf8");
127
  };
128
}
129
130
function catalogDescription(catalog: ReadonlyArray<PluginCatalogEntry>): string {
131
  const entries = catalog
132
    .map((entry) => `- \`${entry.name}\` v${entry.version}: ${entry.description}`)
133
    .join("\n");
134
  return catalog.length === 0
135
    ? "The local catalog is empty."
136
    : `Installed capabilities:\n${entries}`;
137
}
138
139
/**
140
 * The one standing tool for discovering and loading plugin capabilities.
141
 *
142
 * The description and parameters contain only the `capability` tool: no
143
 * installed plugin names, no plugin parameter schemas, and no catalog enum.
144
 * Those appear only after a search with `query` returns the catalog and an
145
 * exact-name call with `name` loads the chosen plugin.
146
 */
147
export function capabilityTool(options: CapabilityOptions): CoderTool {
148
  const { catalog, approval, recordGap, onSelect, load = loadPluginFromManifest } = options;
149
  return {
150
    name: "capability",
151
    description:
152
      "Discover and load a local plugin capability from the installed catalog. " +
153
      "No semantic embedding is available in this package, so `query` returns " +
154
      "the full catalog of installed capabilities and their descriptions for you " +
155
      "to choose from. Do not try to guess a name by substring or keyword. " +
156
      "Once you see the exact catalog name, call `capability` again with `name` " +
157
      "set to that exact name to load it and make its dedicated tool available. " +
158
      "Every later call to the loaded capability uses that exact catalog name as the tool name.",
159
    parameters: {
160
      type: "object",
161
      properties: {
162
        query: {
163
          type: "string",
164
          description:
165
            "Describe the capability you need. No semantic embedding is available, so the full catalog is returned.",
166
        },
167
        name: {
168
          type: "string",
169
          description:
170
            "Exact catalog name of the capability to load. Use the exact name from a previous `query` result.",
171
        },
172
      },
173
      additionalProperties: false,
174
    },
175
    run: async (args, _signal) => {
176
      const query = typeof args["query"] === "string" ? args["query"].trim() : undefined;
177
      const name = typeof args["name"] === "string" ? args["name"].trim() : undefined;
178
179
      if (name !== undefined && name.length > 0) {
180
        const entry = catalog.find((candidate) => candidate.name === name);
181
        if (entry === undefined) {
182
          await recordGap({ requestedAt: Date.now(), requestedName: name, query });
183
          return `No capability named \`${name}\` is in the local catalog.\n\n${catalogDescription(catalog)}`;
184
        }
185
186
        const approvalResult = await approval.check({
187
          name: entry.name,
188
          digest: entry.artifact.digest,
189
          capabilities: entry.capabilities,
190
        });
191
        if (isRefusal(approvalResult)) {
192
          return `Capability \`${name}\` was not allowed (${approvalResult.code}): ${approvalResult.reason}`;
193
        }
194
195
        const outcome = load(entry.manifestPath);
196
        if (isRefusal(outcome)) {
197
          return describeLoad(outcome);
198
        }
199
        onSelect(outcome, entry.manifestPath);
200
        return describeLoad(outcome);
201
      }
202
203
      if (query !== undefined && query.length > 0) {
204
        if (catalog.length === 0) {
205
          await recordGap({ requestedAt: Date.now(), query });
206
        }
207
        return (
208
          "No semantic embedding is available, so the full catalog is shown for you to choose.\n\n" +
209
          `${catalogDescription(catalog)}\n\n` +
210
          "Call `capability` with `name` set to the exact catalog name you want to load."
211
        );
212
      }
213
214
      return "Provide `query` to see the catalog or `name` to load a capability by exact catalog name.";
215
    },
216
  };
217
}
packages/openagents-cli/src/coder-plugin-engine.ts modified +3 -1

@@ -47,7 +47,9 @@ export interface PluginRefusal {

47 47
    | "timeout"
48 48
    | "cancelled"
49 49
    | "trap"
50
    | "bad_packet";
50
    | "bad_packet"
51
    | "approval_unavailable"
52
    | "approval_refused";
51 53
  readonly reason: string;
52 54
}
53 55
packages/openagents-cli/src/coder-plugins.ts modified +51 -1

@@ -228,7 +228,7 @@ export function loadPluginFromManifest(

228 228
  return { manifest, wasm, digest, mounts };
229 229
}
230 230
231
function validateManifest(value: unknown): PluginManifest | PluginRefusal {
231
export function validateManifest(value: unknown): PluginManifest | PluginRefusal {
232 232
  const bad = (what: string): PluginRefusal =>
233 233
    refuse("manifest_invalid", `the manifest is missing or mistypes ${what}`);
234 234

@@ -469,3 +469,53 @@ export function describeLoad(outcome: LoadedPlugin | PluginRefusal): string {

469 469
    "declared to the model for this session. Experimental."
470 470
  );
471 471
}
472
473
/**
474
 * Plugin capability approval.
475
 *
476
 * The host has three fixed tiers:
477
 * - pure compute (no mounts and no declared hosts) is allowed without asking;
478
 * - read-only mounts are asked once and then cached for the same verified
479
 *   digest and declared capabilities;
480
 * - network hosts or writable mounts are asked every time, with no cache.
481
 *
482
 * If no approver is configured, any tier that needs an operator refuses. This
483
 * is the safe default: an unattended session must not grant high-surface
484
 * capabilities without an explicit operator. An interactive caller can supply
485
 * an approver that prompts.
486
 */
487
export interface PluginApprovalRequest {
488
  readonly name: string;
489
  readonly digest: string;
490
  readonly capabilities: {
491
    readonly mounts: ReadonlyArray<{ readonly path: string; readonly readonly: boolean }>;
492
    readonly hosts: ReadonlyArray<unknown>;
493
  };
494
}
495
496
export interface PluginApprover {
497
  ask(request: PluginApprovalRequest): "allow" | "refuse" | Promise<"allow" | "refuse">;
498
}
499
500
export class PluginApproval {
501
  private readonly approved = new Set<string>();
502
  constructor(private readonly approver?: PluginApprover) {}
503
  async check(request: PluginApprovalRequest): Promise<"approved" | PluginRefusal> {
504
    const hasHosts = request.capabilities.hosts.length > 0;
505
    const hasWritableMounts = request.capabilities.mounts.some((mount) => !mount.readonly);
506
    const hasMounts = request.capabilities.mounts.length > 0;
507
    if (!hasMounts && !hasHosts) return "approved";
508
    const askEveryTime = hasHosts || hasWritableMounts;
509
    const key = `${request.digest}:${JSON.stringify(request.capabilities)}`;
510
    if (!askEveryTime && this.approved.has(key)) return "approved";
511
    if (this.approver === undefined) {
512
      return refuse("approval_unavailable", "No approver is configured for this capability tier.");
513
    }
514
    const answer = await this.approver.ask(request);
515
    if (answer !== "allow") {
516
      return refuse("approval_refused", "The operator refused this capability.");
517
    }
518
    if (!askEveryTime) this.approved.add(key);
519
    return "approved";
520
  }
521
}
packages/openagents-cli/test/coder-capability.test.ts added +230

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

1
import { fileURLToPath } from "node:url";
2
3
import { describe, expect, it } from "vitest";
4
5
import {
6
  capabilityTool,
7
  defaultCapabilityGapRecorder,
8
  discoverPluginCatalog,
9
  type CapabilityGap,
10
  type PluginCatalogEntry,
11
} from "../src/coder-capability.js";
12
import {
13
  isRefusal,
14
  loadPluginFromManifest,
15
  PluginApproval,
16
  type LoadedPlugin,
17
  type PluginApprovalRequest,
18
} from "../src/coder-plugins.js";
19
20
const MANIFEST = fileURLToPath(
21
  new URL("../../../plugins/word-stats/manifest.json", import.meta.url),
22
);
23
24
const loadedFixture = ((): LoadedPlugin => {
25
  const outcome = loadPluginFromManifest(MANIFEST);
26
  if (isRefusal(outcome)) throw new Error(`${outcome.code}: ${outcome.reason}`);
27
  return outcome;
28
})();
29
30
const baseCatalog: ReadonlyArray<PluginCatalogEntry> = [
31
  {
32
    name: loadedFixture.manifest.name,
33
    version: loadedFixture.manifest.version,
34
    description: loadedFixture.manifest.description,
35
    manifestPath: MANIFEST,
36
    artifact: loadedFixture.manifest.artifact,
37
    capabilities: loadedFixture.manifest.capabilities,
38
  },
39
];
40
41
describe("capabilityTool", () => {
42
  it("declares one standing tool whose description does not grow with the catalog", () => {
43
    const bigCatalog = Array.from({ length: 20 }, (_unused, index) => ({
44
      ...baseCatalog[0],
45
      name: `demo_${String(index)}`,
46
    }));
47
    const tool = capabilityTool({
48
      catalog: bigCatalog,
49
      approval: new PluginApproval(),
50
      recordGap: () => {},
51
      onSelect: () => {},
52
    });
53
    expect(tool.name).toBe("capability");
54
    const properties = tool.parameters["properties"] as Record<string, unknown>;
55
    expect(Object.keys(properties)).toContain("query");
56
    expect(Object.keys(properties)).toContain("name");
57
    expect(tool.description).toMatch(/No semantic embedding is available/);
58
    expect(tool.description).not.toMatch(/demo_/);
59
    expect(tool.parameters["additionalProperties"]).toBe(false);
60
  });
61
62
  it("returns the full catalog on a query and does not use keyword selection", async () => {
63
    const catalog: ReadonlyArray<PluginCatalogEntry> = [
64
      baseCatalog[0],
65
      { ...baseCatalog[0], name: "git_lost_work", description: "Scan a git repository." },
66
    ];
67
    const tool = capabilityTool({
68
      catalog,
69
      approval: new PluginApproval(),
70
      recordGap: () => {},
71
      onSelect: () => {},
72
    });
73
    const output = await tool.run({ query: "word" }, new AbortController().signal);
74
    expect(output).toContain("word_stats");
75
    expect(output).toContain("git_lost_work");
76
    expect(output).toMatch(/No semantic embedding is available/);
77
  });
78
79
  it("loads and selects a capability by exact catalog name", async () => {
80
    const selected: Array<{ readonly plugin: LoadedPlugin; readonly manifestPath: string }> = [];
81
    const tool = capabilityTool({
82
      catalog: baseCatalog,
83
      approval: new PluginApproval(),
84
      recordGap: () => {},
85
      onSelect: (plugin, manifestPath) => selected.push({ plugin, manifestPath }),
86
      load: loadPluginFromManifest,
87
    });
88
    const output = await tool.run({ name: "word_stats" }, new AbortController().signal);
89
    expect(output).toMatch(/Loaded plugin `word_stats`/);
90
    expect(selected).toHaveLength(1);
91
    expect(selected[0].plugin.manifest.name).toBe("word_stats");
92
  });
93
94
  it("records a capability gap when an exact name is not in the catalog", async () => {
95
    const gaps: CapabilityGap[] = [];
96
    const tool = capabilityTool({
97
      catalog: baseCatalog,
98
      approval: new PluginApproval(),
99
      recordGap: (gap) => {
100
        gaps.push(gap);
101
      },
102
      onSelect: () => {
103
        throw new Error("onSelect should not be called for a missing capability");
104
      },
105
    });
106
    const output = await tool.run({ name: "not_installed" }, new AbortController().signal);
107
    expect(gaps).toHaveLength(1);
108
    expect(gaps[0].requestedName).toBe("not_installed");
109
    expect(output).toMatch(/No capability named `not_installed`/);
110
  });
111
112
  it("records a gap when the catalog is empty on a query", async () => {
113
    const gaps: CapabilityGap[] = [];
114
    const tool = capabilityTool({
115
      catalog: [],
116
      approval: new PluginApproval(),
117
      recordGap: (gap) => {
118
        gaps.push(gap);
119
      },
120
      onSelect: () => {},
121
    });
122
    const output = await tool.run({ query: "something" }, new AbortController().signal);
123
    expect(gaps).toHaveLength(1);
124
    expect(gaps[0].query).toBe("something");
125
    expect(output).toMatch(/The local catalog is empty/);
126
  });
127
});
128
129
describe("PluginApproval", () => {
130
  it("auto-approves capabilities with no mounts and no hosts", async () => {
131
    const asked: PluginApprovalRequest[] = [];
132
    const approval = new PluginApproval({
133
      ask: (request) => {
134
        asked.push(request);
135
        return "allow";
136
      },
137
    });
138
    const result = await approval.check({
139
      name: "pure",
140
      digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
141
      capabilities: { mounts: [], hosts: [] },
142
    });
143
    expect(result).toBe("approved");
144
    expect(asked).toHaveLength(0);
145
  });
146
147
  it("asks once for read-only mounts and caches by digest and capabilities", async () => {
148
    const asked: PluginApprovalRequest[] = [];
149
    const approval = new PluginApproval({
150
      ask: (request) => {
151
        asked.push(request);
152
        return "allow";
153
      },
154
    });
155
    const request = {
156
      name: "reader",
157
      digest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
158
      capabilities: { mounts: [{ path: "data", readonly: true as boolean }], hosts: [] },
159
    };
160
    expect(await approval.check(request)).toBe("approved");
161
    expect(await approval.check(request)).toBe("approved");
162
    expect(asked).toHaveLength(1);
163
  });
164
165
  it("asks every time for declared hosts", async () => {
166
    const asked: PluginApprovalRequest[] = [];
167
    const approval = new PluginApproval({
168
      ask: (request) => {
169
        asked.push(request);
170
        return "allow";
171
      },
172
    });
173
    const request = {
174
      name: "net",
175
      digest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
176
      capabilities: { mounts: [], hosts: ["api.example.com"] },
177
    };
178
    await approval.check(request);
179
    await approval.check(request);
180
    expect(asked).toHaveLength(2);
181
  });
182
183
  it("asks every time for writable mounts", async () => {
184
    const asked: PluginApprovalRequest[] = [];
185
    const approval = new PluginApproval({
186
      ask: (request) => {
187
        asked.push(request);
188
        return "allow";
189
      },
190
    });
191
    const request = {
192
      name: "writer",
193
      digest: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
194
      capabilities: { mounts: [{ path: "data", readonly: false }], hosts: [] },
195
    };
196
    await approval.check(request);
197
    await approval.check(request);
198
    expect(asked).toHaveLength(2);
199
  });
200
201
  it("refuses non-pure capabilities when no approver is configured", async () => {
202
    const approval = new PluginApproval();
203
    const result = await approval.check({
204
      name: "reader",
205
      digest: "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
206
      capabilities: { mounts: [{ path: "data", readonly: true }], hosts: [] },
207
    });
208
    expect(isRefusal(result) && result.code).toBe("approval_unavailable");
209
  });
210
});
211
212
describe("discoverPluginCatalog", () => {
213
  it("discovers the checked-in demo plugins from this repository", () => {
214
    const catalog = discoverPluginCatalog(fileURLToPath(import.meta.url));
215
    const names = catalog.map((entry) => entry.name).sort();
216
    expect(names).toContain("word_stats");
217
    expect(names).toContain("dir_stats");
218
    expect(names).toContain("file_stats");
219
    expect(names).toContain("git_lost_work");
220
    expect(names).toContain("foreign_sessions");
221
    expect(catalog.every((entry) => entry.artifact.digest.startsWith("sha256:"))).toBe(true);
222
  });
223
});
224
225
describe("defaultCapabilityGapRecorder", () => {
226
  it("produces an async writer for the default gap file", () => {
227
    const recorder = defaultCapabilityGapRecorder();
228
    expect(typeof recorder).toBe("function");
229
  });
230
});

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