@jini-ai/agent-plugins 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/core/persistent-state.d.ts +25 -0
- package/core/persistent-state.js +260 -0
- package/dist/lifecycle/activation.js +38 -166
- package/dist/lifecycle/bundled-digests.d.ts +6 -0
- package/dist/lifecycle/bundled-digests.js +160 -0
- package/dist/lifecycle/bundled-source-archive.d.ts +3 -0
- package/dist/lifecycle/bundled-source-archive.js +74 -0
- package/dist/lifecycle/contribution-loader.d.ts +102 -0
- package/dist/lifecycle/contribution-loader.js +92 -0
- package/dist/lifecycle/fetch-archive.d.ts +1 -2
- package/dist/lifecycle/index.d.ts +24 -4
- package/dist/lifecycle/index.js +11 -0
- package/dist/lifecycle/install-from-url.d.ts +2 -5
- package/dist/lifecycle/install-from-url.js +2 -5
- package/dist/lifecycle/install.d.ts +58 -3
- package/dist/lifecycle/install.js +301 -23
- package/dist/lifecycle/installed-package-roots.d.ts +16 -0
- package/dist/lifecycle/installed-package-roots.js +55 -0
- package/dist/lifecycle/layout.js +1 -1
- package/dist/lifecycle/manifest.d.ts +5 -60
- package/dist/lifecycle/manifest.js +12 -20
- package/dist/lifecycle/package-paths.d.ts +2 -0
- package/dist/lifecycle/package-paths.js +62 -0
- package/dist/lifecycle/persistent-state.d.ts +5 -1
- package/dist/lifecycle/persistent-state.js +3 -1
- package/dist/lifecycle/ports.d.ts +20 -2
- package/dist/lifecycle/resolve-agent-plugin-refs.d.ts +13 -1
- package/dist/lifecycle/resolve-agent-plugin-refs.js +150 -40
- package/dist/lifecycle/retire-bundled.d.ts +50 -1
- package/dist/lifecycle/retire-bundled.js +9 -2
- package/dist/lifecycle/search.d.ts +65 -0
- package/dist/lifecycle/search.js +31 -2
- package/dist/lifecycle/seed-bundled.d.ts +18 -1
- package/dist/lifecycle/seed-bundled.js +131 -3
- package/dist/lifecycle/set-enabled.d.ts +7 -0
- package/dist/lifecycle/set-enabled.js +5 -0
- package/dist/lifecycle/trusted-plugin-files.d.ts +38 -0
- package/dist/lifecycle/trusted-plugin-files.js +18 -0
- package/dist/lifecycle/types.d.ts +14 -0
- package/dist/lifecycle/uninstall.d.ts +29 -0
- package/dist/lifecycle/uninstall.js +204 -4
- package/dist/lifecycle/yauzl-archive-reader.d.ts +0 -1
- package/dist/manifest.d.ts +3 -0
- package/dist/manifest.js +3 -2
- package/package.json +22 -8
- package/persistent-state.js +2 -260
- package/ui-ux-design/plugin.json +15 -1
|
@@ -1,18 +1,83 @@
|
|
|
1
1
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
2
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
3
|
+
/**
|
|
4
|
+
* @file Pure relevance ranking for `search_agent_plugin_local` — turns one workspace's already-
|
|
5
|
+
* resolved installed Agent Plugins into scored matches for a free-text query. No filesystem or
|
|
6
|
+
* network I/O; `tool-registrations.ts` is the caller that reads disk and hands this module already-
|
|
7
|
+
* resolved {@link AgentPluginSearchCandidate}s.
|
|
8
|
+
*
|
|
9
|
+
* ---------------------------------------------------------------------------
|
|
10
|
+
* Why field-weighted substring matching, not FTS5/BM25
|
|
11
|
+
* ---------------------------------------------------------------------------
|
|
12
|
+
* `assistant/tool-catalog-query.ts` (the `search_tools` index this tool itself is found through)
|
|
13
|
+
* seeds a real SQLite FTS5 table because its catalog is runtime-registered and can be large (170+
|
|
14
|
+
* tools, changing per boot/workspace). The catalog THIS function ranks over is different in kind:
|
|
15
|
+
* one workspace's own installed Agent Plugins, read fresh off disk on every call, realistically a
|
|
16
|
+
* handful of entries. `assistant/component-catalog-query.ts` (`search_components`) already
|
|
17
|
+
* establishes the house precedent for exactly this shape — "a handful of manifests doesn't warrant
|
|
18
|
+
* a database for substring matching" — and ranks with plain `haystack.includes(term)` scoring. This
|
|
19
|
+
* module reuses that same technique rather than inventing a second one, with one deliberate
|
|
20
|
+
* difference from that precedent: per-field WEIGHTS instead of one flat merged haystack.
|
|
21
|
+
*
|
|
22
|
+
* The weights are not decorative. `plugin.json` (agent-plugins.org) carries a `keywords` array
|
|
23
|
+
* that `component-catalog-query.ts`'s manifests have no equivalent of — author-curated discovery
|
|
24
|
+
* terms, the exact same "operator asks in different words than the code uses" gap
|
|
25
|
+
* `tool-search-keywords.ts`'s own header describes at length for native tools. A keyword hit is a
|
|
26
|
+
* stronger, more deliberate signal than an incidental word landing inside free-form prose, so it is
|
|
27
|
+
* scored higher. The id (`pluginId`) is scored highest of all: it is what a caller actually types
|
|
28
|
+
* back to `plugins_set_enabled`-shaped tools next, so an exact or near match on it (a caller typing
|
|
29
|
+
* "site-compliance" or "compliance") must win outright — mirroring `tool-catalog.ts`'s own
|
|
30
|
+
* documented "id weighted 6x description" ratio for the identical reason (id IS the thing quoted
|
|
31
|
+
* back). Skill name/summary text is weighted lowest: it is the broadest, noisiest vocabulary (every
|
|
32
|
+
* bundled skill's own frontmatter description folded in), valuable for recall on a query that names
|
|
33
|
+
* a capability rather than a plugin ("shadcn components") but not something that should let one
|
|
34
|
+
* verbose plugin outrank a plugin whose own id/keywords/description directly named the query.
|
|
35
|
+
* Plugin `description` sits in between: real author prose, but not curated tags and not the
|
|
36
|
+
* identity string itself.
|
|
37
|
+
*
|
|
38
|
+
* A plugin's own `version`, `enabled` state, and MCP server ids are carried through on every match
|
|
39
|
+
* for the caller to report, but are NOT scored — none of them are text a caller plausibly searches
|
|
40
|
+
* for ("find me the plugin on version 1.1.0" is not a real query), and an MCP server id is often an
|
|
41
|
+
* arbitrary machine-chosen string with no natural-language relationship to what an operator would
|
|
42
|
+
* type.
|
|
43
|
+
*
|
|
44
|
+
* @complexity `rankInstalledAgentPlugins` is O(c * t * f) — c candidates, t query terms, f fields
|
|
45
|
+
* (a fixed constant, 4) — so effectively O(c * t): linear in the installed-plugin count and query
|
|
46
|
+
* length, both small and locally bounded (there is no user-controlled upper bound on installed-
|
|
47
|
+
* plugin count today, matching the same unbounded-but-locally-small shape
|
|
48
|
+
* `loadInstalledAgentPluginToolSources` already accepts for the very same catalog).
|
|
49
|
+
*/
|
|
50
|
+
/** One installed plugin's skill, reduced to what search ranks and reports on — never the full
|
|
51
|
+
* SKILL.md body (that stays behind `agent_plugin_<pluginId>`'s own tool, which a caller reaches
|
|
52
|
+
* once search has told it which plugin to ask). */
|
|
3
53
|
export interface AgentPluginSearchSkill {
|
|
4
54
|
readonly name: string;
|
|
55
|
+
/** Plain-language explainer from the host extension namespace in `plugin.json`: `summary`, for the admin list
|
|
56
|
+
* route only — not ranked, and the search tool's own response projection leaves it out. */
|
|
5
57
|
readonly summary: string;
|
|
6
58
|
}
|
|
59
|
+
/** One installed plugin, already resolved into everything the ranker and the response need — no
|
|
60
|
+
* `packageRoot`/`skillPath`/any other host-filesystem detail (see `tool-registrations.ts`'s own
|
|
61
|
+
* SECURITY note for why: this candidate shape is what actually reaches the model). */
|
|
7
62
|
export interface AgentPluginSearchCandidate {
|
|
8
63
|
readonly pluginId: string;
|
|
9
64
|
readonly version?: (string) | undefined;
|
|
65
|
+
/** Human-facing title from the host extension namespace in `plugin.json`: `displayName`, when the package set one. */
|
|
66
|
+
readonly displayName?: string | undefined;
|
|
67
|
+
readonly summary?: string | undefined;
|
|
10
68
|
readonly description?: (string) | undefined;
|
|
11
69
|
readonly keywords: readonly string[];
|
|
70
|
+
readonly id?: string | undefined;
|
|
71
|
+
readonly provides?: readonly string[] | undefined;
|
|
72
|
+
/** Whether this workspace currently has the plugin enabled — reflects `activation.ts`'s
|
|
73
|
+
* absent-means-active default, same as everywhere else this codebase reads activation state. */
|
|
12
74
|
readonly enabled: boolean;
|
|
13
75
|
readonly skills: readonly AgentPluginSearchSkill[];
|
|
76
|
+
/** Server ids only, from the package's own `mcp.json` — never transport config (`command`/`args`/
|
|
77
|
+
* `env` can carry secrets; see `capability-projection.ts`'s identical restraint). */
|
|
14
78
|
readonly mcpServerIds: readonly string[];
|
|
15
79
|
}
|
|
80
|
+
/** One ranked result: a candidate plus the score it earned against a specific query. */
|
|
16
81
|
export interface AgentPluginSearchMatch extends AgentPluginSearchCandidate {
|
|
17
82
|
readonly score: number;
|
|
18
83
|
}
|
package/dist/lifecycle/search.js
CHANGED
|
@@ -4,16 +4,22 @@ function buildModule(ports) {
|
|
|
4
4
|
const KEYWORD_FIELD_WEIGHT = 4;
|
|
5
5
|
const DESCRIPTION_FIELD_WEIGHT = 2;
|
|
6
6
|
const SKILL_FIELD_WEIGHT = 1;
|
|
7
|
+
/** Lowercase, whitespace-split query terms — mirrors `component-catalog-query.ts`'s identical
|
|
8
|
+
* `query.toLowerCase().split(/\s+/).filter(Boolean)`, the house tokenizer for this "small local
|
|
9
|
+
* catalog, plain string matching" shape. Empty/whitespace-only input yields no terms. */
|
|
7
10
|
function tokenize(query) {
|
|
8
11
|
return query
|
|
9
12
|
.toLowerCase()
|
|
10
13
|
.split(/\s+/)
|
|
11
14
|
.filter((term) => term.length > 0);
|
|
12
15
|
}
|
|
16
|
+
/** One candidate's scoreable text, one entry per weighted field — see this file's header for why
|
|
17
|
+
* each weight is what it is. Split out of {@link scoreCandidate} so the field/weight pairing is a
|
|
18
|
+
* plain, independently-readable list rather than interleaved with the scoring loop itself. */
|
|
13
19
|
function fieldHaystacks(candidate) {
|
|
14
20
|
return [
|
|
15
|
-
{ text: candidate.pluginId.toLowerCase(), weight: ID_FIELD_WEIGHT },
|
|
16
|
-
{ text: candidate.keywords.join(" ").toLowerCase(), weight: KEYWORD_FIELD_WEIGHT },
|
|
21
|
+
{ text: `${candidate.pluginId} ${candidate.id ?? ""}`.toLowerCase(), weight: ID_FIELD_WEIGHT },
|
|
22
|
+
{ text: [...candidate.keywords, ...(candidate.provides ?? [])].join(" ").toLowerCase(), weight: KEYWORD_FIELD_WEIGHT },
|
|
17
23
|
{ text: (candidate.description ?? "").toLowerCase(), weight: DESCRIPTION_FIELD_WEIGHT },
|
|
18
24
|
{
|
|
19
25
|
text: candidate.skills.map((skill) => `${skill.name} ${skill.summary}`).join(" ").toLowerCase(),
|
|
@@ -21,10 +27,33 @@ function buildModule(ports) {
|
|
|
21
27
|
},
|
|
22
28
|
];
|
|
23
29
|
}
|
|
30
|
+
/** Sums, over every query term and every weighted field, the field's weight whenever that field's
|
|
31
|
+
* text contains the term as a substring — the same `includes()` test `component-catalog-query.ts`
|
|
32
|
+
* uses, just per-field instead of one merged haystack. A term can score against more than one field
|
|
33
|
+
* (e.g. a keyword that is also echoed in the description) — that is intentional: a plugin whose
|
|
34
|
+
* author reinforced a concept in both its keywords and its prose is a genuinely stronger match, not
|
|
35
|
+
* a bug to dedupe away. */
|
|
24
36
|
function scoreCandidate(candidate, terms) {
|
|
25
37
|
const haystacks = fieldHaystacks(candidate);
|
|
26
38
|
return terms.reduce((total, term) => total + haystacks.reduce((fieldTotal, haystack) => (haystack.text.includes(term) ? fieldTotal + haystack.weight : fieldTotal), 0), 0);
|
|
27
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Ranks already-resolved installed-plugin candidates against a free-text query.
|
|
42
|
+
*
|
|
43
|
+
* @param query - Free-text search phrase. An empty or whitespace-only query has no terms to match
|
|
44
|
+
* against and always returns no matches — this function never falls back to "return everything",
|
|
45
|
+
* matching `search_components`' own "no query, no hits" behavior rather than the different (and
|
|
46
|
+
* here wrong) choice of treating an empty query as "list all installed".
|
|
47
|
+
* @param candidates - One workspace's installed plugins, already resolved (disk already read).
|
|
48
|
+
* @param limit - Max matches to return, already clamped by the caller (mirrors
|
|
49
|
+
* `component-catalog-tool.ts`'s handler, which owns the clamp so this function can stay a pure
|
|
50
|
+
* "given these inputs, rank them" step with no knowledge of the tool's own schema bounds).
|
|
51
|
+
* @returns Matches with score > 0, sorted by score descending, truncated to `limit`. A candidate
|
|
52
|
+
* that scores 0 (no term matched anything) is dropped, not returned at the bottom — the caller's own
|
|
53
|
+
* "N installed, 0 matched" framing is what tells the model the workspace is not empty even when a
|
|
54
|
+
* query matches nothing.
|
|
55
|
+
* @complexity See this file's header.
|
|
56
|
+
*/
|
|
28
57
|
function rankInstalledAgentPlugins(query, candidates, limit) {
|
|
29
58
|
const terms = tokenize(query);
|
|
30
59
|
if (terms.length === 0)
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { AgentPluginLayout } from "./layout.js";
|
|
2
|
-
import {
|
|
2
|
+
import type { RetiredAgentPluginOutcome } from "./retire-bundled.js";
|
|
3
3
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
4
4
|
export type SeededAgentPluginOutcome = {
|
|
5
5
|
readonly pluginId: string;
|
|
6
6
|
readonly status: "seeded";
|
|
7
7
|
readonly archiveDigest: string;
|
|
8
|
+
/** True when this boot wrote the initial record (disabled, or enabled for
|
|
9
|
+
* {@link BUNDLED_AGENT_PLUGINS_SEEDED_ENABLED}); false when a decision already existed and was
|
|
10
|
+
* preserved. */
|
|
8
11
|
readonly activationRecorded: boolean;
|
|
9
12
|
} | {
|
|
10
13
|
readonly pluginId: string;
|
|
@@ -12,12 +15,26 @@ export type SeededAgentPluginOutcome = {
|
|
|
12
15
|
readonly reason: string;
|
|
13
16
|
};
|
|
14
17
|
export interface SeedBundledAgentPluginsResult {
|
|
18
|
+
/** Directory holding one subdirectory per bundled plugin. `deps.ts`'s `bundledAgentPluginsDir()`
|
|
19
|
+
* in production. */
|
|
15
20
|
readonly sourceRoot: string;
|
|
16
21
|
readonly outcomes: readonly SeededAgentPluginOutcome[];
|
|
22
|
+
/** Why this boot could not record which digest it published for each plugin
|
|
23
|
+
* (`bundled-digests.ts`), or absent when it did. Reported rather than thrown, for the same
|
|
24
|
+
* failure-isolation reason every per-plugin failure is: a workspace whose ledger cannot be
|
|
25
|
+
* written still has every package installed and every activation decision intact — it only falls
|
|
26
|
+
* back to refusing a multi-digest plugin id, which is exactly the behaviour that predates the
|
|
27
|
+
* ledger. */
|
|
17
28
|
readonly ledgerFailure?: (string) | undefined;
|
|
29
|
+
/** One outcome per retired bundled plugin (`retire-bundled.ts`), run after seeding and the ledger so
|
|
30
|
+
* each successor's own record already exists. Empty when the activation pre-flight refused. */
|
|
18
31
|
readonly retirements: readonly RetiredAgentPluginOutcome[];
|
|
19
32
|
}
|
|
20
33
|
export interface SeedBundledAgentPluginsRequired {
|
|
34
|
+
/** The INSTANCE-level layout (`resolveAgentPluginLayout()`), not a pre-resolved workspace one —
|
|
35
|
+
* `installAgentPlugin` resolves `forWorkspace(workspaceId)` itself, atomically, and this
|
|
36
|
+
* function passes both through unchanged so that guarantee is not weakened in transit. See
|
|
37
|
+
* `install.ts`'s SECURITY note on `InstallAgentPluginRequired.layout`. */
|
|
21
38
|
readonly layout: AgentPluginLayout;
|
|
22
39
|
readonly workspaceId: string;
|
|
23
40
|
readonly sourceRoot: string;
|
|
@@ -1,10 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `seedBundledAgentPlugins()` — installs the Agent Plugins that ship WITH the host into a
|
|
3
|
+
* workspace's own package store, and records each one INACTIVE until an operator says otherwise
|
|
4
|
+
* (except the few core paths depend on, `BUNDLED_AGENT_PLUGINS_SEEDED_ENABLED`, which seed active).
|
|
5
|
+
*
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* "Bundled but inactive", concretely
|
|
8
|
+
* ---------------------------------------------------------------------------
|
|
9
|
+
* Bundled: the package's real files are tracked in this repository under `content/agent-plugins/<id>/`
|
|
10
|
+
* and copied into `dist/` by `npm run build`, exactly like `content/themes/`. A fresh install has them
|
|
11
|
+
* without downloading anything.
|
|
12
|
+
*
|
|
13
|
+
* Inactive: seeding writes `{ enabled: false, origin: "bundled" }` into the workspace's activation
|
|
14
|
+
* record (`activation.ts`) BEFORE the package can be read by anything, and the two places that
|
|
15
|
+
* consume installed plugins both consult that record:
|
|
16
|
+
*
|
|
17
|
+
* - `tool-registrations.ts` — will not register its `agent_plugin_<id>` tool;
|
|
18
|
+
* - `resolve-agent-plugin-refs.ts` — refuses to inject it even if a composer chip pins it by id.
|
|
19
|
+
*
|
|
20
|
+
* (A third surface, `capability-source.ts`'s `capability_search` discovery, was gated here too
|
|
21
|
+
* until that tool pair was removed 2026-08-26 — see
|
|
22
|
+
* `ADS-memory/knowledge/2026-08-26-removed-capability-search.md`.)
|
|
23
|
+
*
|
|
24
|
+
* So "inactive" means genuinely unexecutable, not merely unadvertised: its skills never reach a
|
|
25
|
+
* prompt, and its tools and MCP servers are never registered.
|
|
26
|
+
*
|
|
27
|
+
* It does NOT mean hidden from the operator. The admin Agent Plugins screen lists every installed
|
|
28
|
+
* plugin, with an inactive one's switch shown off, so a bundled plugin can be found and turned on
|
|
29
|
+
* there (owner decision, 2026-09-13; before it, that screen's default tab listed only active ones).
|
|
30
|
+
* Listing only READS the activation record, so the "absent means active" hazard described below is
|
|
31
|
+
* unaffected.
|
|
32
|
+
*
|
|
33
|
+
* ---------------------------------------------------------------------------
|
|
34
|
+
* Why it runs on every boot
|
|
35
|
+
* ---------------------------------------------------------------------------
|
|
36
|
+
* Seeding is idempotent twice over: `installAgentPlugin` short-circuits when the content digest is
|
|
37
|
+
* already published, and `recordBundledAgentPluginIfAbsent` never overwrites an existing decision.
|
|
38
|
+
* Re-running it therefore costs one directory walk and one hash, and buys two things a one-shot
|
|
39
|
+
* first-run seed would not:
|
|
40
|
+
*
|
|
41
|
+
* 1. A product upgrade that ships a NEW bundled plugin, or a NEW VERSION of one, reaches existing
|
|
42
|
+
* workspaces without a migration step.
|
|
43
|
+
*
|
|
44
|
+
* The second half of that claim was FALSE IN EFFECT until 2026-09-18, and the correction is the
|
|
45
|
+
* reason `bundled-digests.ts` exists. The package store is content-addressed and nothing retires
|
|
46
|
+
* an entry, so a revised bundled plugin installs ALONGSIDE its predecessor rather than replacing
|
|
47
|
+
* it — after which both consumption surfaces refused the plugin outright as ambiguous
|
|
48
|
+
* (`resolve-agent-plugin-refs.ts`'s "refusing to guess which one to use",
|
|
49
|
+
* `tool-registrations.ts`'s `poisonedPluginIds`). An upgrade did reach the workspace; it also
|
|
50
|
+
* silently broke the plugin. What makes the claim true now is the ledger this function writes
|
|
51
|
+
* below: the seed RECORDS which digest this build published, so the read side has the build's own
|
|
52
|
+
* answer to "which one" instead of a guess. The superseded bytes stay on disk — see
|
|
53
|
+
* `bundled-digests.ts`'s header for why nothing is deleted, and this file's own report for what
|
|
54
|
+
* an operator still has to prune by hand.
|
|
55
|
+
* 2. Deleting `activations.json` re-creates the bundled plugin's DISABLED record. Without the
|
|
56
|
+
* re-run, deleting that file would silently promote every bundled plugin to active, because
|
|
57
|
+
* `isAgentPluginActive`'s "absent means active" default is what keeps operator-installed plugins
|
|
58
|
+
* working. The boot-time re-seed is what closes that direction.
|
|
59
|
+
*
|
|
60
|
+
* A file that is PRESENT but unreadable is a different case from a deleted one, and is not
|
|
61
|
+
* treated the same way (t91 F1.1, 2026-09-16): seeding refuses outright rather than install
|
|
62
|
+
* around the fault, and every bundled plugin comes back `failed` with the recovery text
|
|
63
|
+
* `activation.ts`'s `AgentPluginActivationsUnreadableError` gives — see this file's header,
|
|
64
|
+
* "Failure isolation" below, and `activation.ts`'s own header, "Writers never rewrite what they
|
|
65
|
+
* could not read".
|
|
66
|
+
*
|
|
67
|
+
* ---------------------------------------------------------------------------
|
|
68
|
+
* Failure isolation
|
|
69
|
+
* ---------------------------------------------------------------------------
|
|
70
|
+
* One unusable bundled package must never stop a workspace from booting or hide the others. Each is
|
|
71
|
+
* seeded independently and its failure is captured into the returned report, not thrown. The caller
|
|
72
|
+
* decides whether to log it; nothing here writes to the console, so this stays a pure-ish function
|
|
73
|
+
* a test can assert on directly.
|
|
74
|
+
*
|
|
75
|
+
* Architectural role:
|
|
76
|
+
* Composition over `bundled-source-archive.ts` (directory to archive), `install.ts`
|
|
77
|
+
* (`installAgentPlugin`, where every extraction guarantee lives), and `activation.ts` (the record).
|
|
78
|
+
* No guarantee of its own.
|
|
79
|
+
*/
|
|
1
80
|
import { createPersistentStateModule } from "./persistent-state.js";
|
|
2
81
|
import path from "node:path";
|
|
3
82
|
import { createActivationModule } from "./activation.js";
|
|
4
83
|
import { createBundledDigestsModule } from "./bundled-digests.js";
|
|
5
84
|
import { createBundledSourceArchiveModule } from "./bundled-source-archive.js";
|
|
6
85
|
import { createInstallModule } from "./install.js";
|
|
7
|
-
import {} from "./retire-bundled.js";
|
|
8
86
|
import { createRetireBundledModule } from "./retire-bundled.js";
|
|
9
87
|
function buildModule(ports) {
|
|
10
88
|
const readdir = ports.filesystem.readdir.bind(ports.filesystem);
|
|
@@ -14,13 +92,38 @@ function buildModule(ports) {
|
|
|
14
92
|
const { createBundledSourceArchiveReader, packAgentPluginDirectory } = createBundledSourceArchiveModule(ports);
|
|
15
93
|
const { installAgentPlugin } = createInstallModule(ports);
|
|
16
94
|
const { retireBundledAgentPlugins } = createRetireBundledModule(ports);
|
|
95
|
+
/**
|
|
96
|
+
* Installs every bundled Agent Plugin into one workspace and records each inactive-by-default,
|
|
97
|
+
* except {@link BUNDLED_AGENT_PLUGINS_SEEDED_ENABLED}.
|
|
98
|
+
*
|
|
99
|
+
* @returns A per-plugin report. An absent or unreadable `sourceRoot` yields an empty `outcomes`
|
|
100
|
+
* list rather than an error: a build that shipped no bundled plugins is a legitimate configuration,
|
|
101
|
+
* and a self-hosted operator who deleted the directory has made a choice this function should not
|
|
102
|
+
* override by crashing.
|
|
103
|
+
* @throws Nothing. Every per-plugin failure is captured into the report.
|
|
104
|
+
* @complexity O(p) plugins, each O(f) files and O(b) bytes — all bounded by
|
|
105
|
+
* `bundled-source-archive.ts`'s file cap and `install.ts`'s own byte caps.
|
|
106
|
+
*/
|
|
107
|
+
// The migration must finish before new installs, including bundled packages, are published.
|
|
108
|
+
// Rejected legacy entries are preserved in quarantine and count as handled. An incomplete run
|
|
109
|
+
// means a move/quarantine failed; a held lock also refuses seeding until migration can finish.
|
|
110
|
+
// ONE strict pre-flight read BEFORE any install — so a bundled package never lands on disk
|
|
111
|
+
// without the activation record that would keep it inactive (this file's header). Every bundled
|
|
112
|
+
// plugin is reported failed with the SAME reason, rather than attempting each one and hitting the
|
|
113
|
+
// identical fault p times.
|
|
114
|
+
// AFTER every install, never before: the ledger names digests, and a digest is only worth naming
|
|
115
|
+
// once its bytes are actually on disk for this workspace.
|
|
116
|
+
// AFTER seeding and the ledger: a retired plugin's successor is itself bundled, so its record and
|
|
117
|
+
// its ledger entry exist by now, and the retirement only has to decide whether to switch it on.
|
|
17
118
|
async function seedBundledAgentPlugins(required) {
|
|
18
119
|
const { layout, workspaceId, sourceRoot } = required;
|
|
19
120
|
const pluginDirNames = await listBundledPluginDirs(sourceRoot);
|
|
20
121
|
try {
|
|
21
|
-
const migration =
|
|
122
|
+
const migration = ports.migrateBeforeSeed
|
|
123
|
+
? await ports.migrateBeforeSeed({ layout, workspaceId }, {})
|
|
124
|
+
: await createPersistentStateModule(ports).migrate({ workspaceRoot: layout.forWorkspace({ workspaceId }).root });
|
|
22
125
|
if (!migration.complete)
|
|
23
|
-
throw new Error('Legacy plugin migration
|
|
126
|
+
throw new Error('Legacy plugin layout migration has unfinished filesystem moves; see boot log');
|
|
24
127
|
}
|
|
25
128
|
catch (error) {
|
|
26
129
|
const reason = error instanceof Error ? error.message : String(error);
|
|
@@ -42,6 +145,11 @@ function buildModule(ports) {
|
|
|
42
145
|
const retirements = await retireBundledAgentPlugins({ layout, workspaceId });
|
|
43
146
|
return { sourceRoot, outcomes, retirements, ...(ledgerFailure !== undefined ? { ledgerFailure } : {}) };
|
|
44
147
|
}
|
|
148
|
+
/** Records which digest this boot published for each plugin that seeded successfully, and returns
|
|
149
|
+
* why it could not, rather than throwing — {@link seedBundledAgentPlugins}'s own contract is
|
|
150
|
+
* "@throws Nothing", and a ledger this boot failed to write costs the workspace nothing it had
|
|
151
|
+
* before the ledger existed.
|
|
152
|
+
* @complexity One file read and one atomic write. */
|
|
45
153
|
async function recordSeededDigests(workspaceRoot, outcomes) {
|
|
46
154
|
try {
|
|
47
155
|
await recordBundledAgentPluginDigests({
|
|
@@ -54,6 +162,11 @@ function buildModule(ports) {
|
|
|
54
162
|
return error instanceof Error ? error.message : String(error);
|
|
55
163
|
}
|
|
56
164
|
}
|
|
165
|
+
/** Why nothing may be seeded into this workspace right now, or `undefined`. One strict read BEFORE
|
|
166
|
+
* any install, so a bundled package never lands on disk without the record that keeps it inactive.
|
|
167
|
+
* Catches everything (including `forWorkspace`'s own throw) to keep this function's own contract
|
|
168
|
+
* "@throws Nothing", matching {@link seedBundledAgentPlugins}'s own guarantee.
|
|
169
|
+
* @complexity One file read. */
|
|
57
170
|
async function activationsRefusal(layout, workspaceId) {
|
|
58
171
|
try {
|
|
59
172
|
await assertAgentPluginActivationsWritable(layout.forWorkspace({ workspaceId }).root);
|
|
@@ -63,6 +176,9 @@ function buildModule(ports) {
|
|
|
63
176
|
return error instanceof Error ? error.message : String(error);
|
|
64
177
|
}
|
|
65
178
|
}
|
|
179
|
+
// The plugin's OWN manifest name, not the directory name — `install.ts` reads the id from
|
|
180
|
+
// `plugin.json`, and every consumer keys activation off that same id. Using the folder name
|
|
181
|
+
// here would produce a record nothing ever consults if the two ever disagreed.
|
|
66
182
|
async function seedOne(args) {
|
|
67
183
|
try {
|
|
68
184
|
const packed = await packAgentPluginDirectory(args.sourceDir);
|
|
@@ -89,6 +205,17 @@ function buildModule(ports) {
|
|
|
89
205
|
};
|
|
90
206
|
}
|
|
91
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Lists the subdirectories of `sourceRoot` that actually look like Agent Plugin packages (they hold
|
|
210
|
+
* a `plugin.json` at their own root, which is where `indexInstalledRoot` looks for it).
|
|
211
|
+
*
|
|
212
|
+
* Filtering here rather than letting the installer reject them keeps a stray `README.md` or an
|
|
213
|
+
* editor's scratch directory from showing up in the report as a failed plugin.
|
|
214
|
+
*
|
|
215
|
+
* @complexity O(d) in the subdirectory count.
|
|
216
|
+
*/
|
|
217
|
+
// A retired id is never seeded, even from a stale build that still carries its directory —
|
|
218
|
+
// otherwise every boot would reinstall it only for `retire-bundled.ts` to remove it again.
|
|
92
219
|
async function listBundledPluginDirs(sourceRoot) {
|
|
93
220
|
let entries;
|
|
94
221
|
try {
|
|
@@ -109,6 +236,7 @@ function buildModule(ports) {
|
|
|
109
236
|
dirs.push(entry.name);
|
|
110
237
|
}
|
|
111
238
|
catch {
|
|
239
|
+
// No plugin.json: not a package. Silently skipped — see this function's own doc.
|
|
112
240
|
}
|
|
113
241
|
}
|
|
114
242
|
return dirs.sort();
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* The id named is not installed in this workspace. A distinct class rather than a bare `Error` so
|
|
4
|
+
* each caller can map it to its own boundary's vocabulary — 404 `AGENT_PLUGIN_NOT_FOUND` at the
|
|
5
|
+
* admin route, `ToolInputError` at the tool — without either one string-matching a message.
|
|
6
|
+
*/
|
|
2
7
|
export declare class AgentPluginNotInstalledError extends Error {
|
|
3
8
|
constructor({ pluginId }: {
|
|
4
9
|
readonly pluginId: string;
|
|
@@ -8,10 +13,12 @@ export interface SetAgentPluginEnabledInput {
|
|
|
8
13
|
readonly workspaceId: string;
|
|
9
14
|
readonly pluginId: string;
|
|
10
15
|
readonly enabled: boolean;
|
|
16
|
+
/** Recorded as the activation record's updatedBy. The authenticated principal, never a constant. */
|
|
11
17
|
readonly actor: string;
|
|
12
18
|
}
|
|
13
19
|
export interface SetAgentPluginEnabledResult {
|
|
14
20
|
readonly pluginId: string;
|
|
21
|
+
/** As actually written and read back, not as requested. */
|
|
15
22
|
readonly enabled: boolean;
|
|
16
23
|
}
|
|
17
24
|
/** Internal composition; one state/queue instance per injected host context. */
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
2
|
import { createActivationModule } from "./activation.js";
|
|
3
3
|
import { createResolveAgentPluginRefsModule } from "./resolve-agent-plugin-refs.js";
|
|
4
|
+
/**
|
|
5
|
+
* The id named is not installed in this workspace. A distinct class rather than a bare `Error` so
|
|
6
|
+
* each caller can map it to its own boundary's vocabulary — 404 `AGENT_PLUGIN_NOT_FOUND` at the
|
|
7
|
+
* admin route, `ToolInputError` at the tool — without either one string-matching a message.
|
|
8
|
+
*/
|
|
4
9
|
export class AgentPluginNotInstalledError extends Error {
|
|
5
10
|
constructor({ pluginId }) {
|
|
6
11
|
super(`agent plugin '${pluginId}' is not installed in this workspace`);
|
|
@@ -1,18 +1,56 @@
|
|
|
1
1
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file The shared trust gate for plugin-contributed files that core reads or runs: deploy targets
|
|
4
|
+
* (`features/deployments/deploy-targets/registry.ts`), deploy-config generators, credential
|
|
5
|
+
* scheme rules, mail adapters (`./mail-adapter-registry.ts`) and git-host providers
|
|
6
|
+
* (`features/source-control/provider-registry.ts`). One place decides which installed Agent Plugin packages core may take a contribution
|
|
7
|
+
* from, so every seam applies the same rules.
|
|
8
|
+
*
|
|
9
|
+
* A plugin opts in to a seam by shipping that seam's file at its package root (e.g.
|
|
10
|
+
* a host-supplied deploy-target descriptor filename). {@link findTrustedPluginPackages} judges each package that ships it
|
|
11
|
+
* against the gates:
|
|
12
|
+
* 1. ACTIVE by the fail-closed reader (`resolveAgentPluginActivation`) when the seam asks for it
|
|
13
|
+
* (`requireActive`). An unreadable activation record refuses rather than reading as consent. A
|
|
14
|
+
* data-only seam may skip this gate; its own file header must say why.
|
|
15
|
+
* 2. Its installed digest is the one `bundled-digests.json` records for it, the build's own seed.
|
|
16
|
+
* An operator-installed package, or a tampered copy under a bundled id, never contributes
|
|
17
|
+
* (owner decision 2026-09-29: third-party contributions come later). Always applied.
|
|
18
|
+
*
|
|
19
|
+
* {@link importContainedModule} is the third gate for seams that run code: the module path must
|
|
20
|
+
* resolve inside the package root on disk (`assertContainedOnDisk`) and end in `.mjs` (the caller's
|
|
21
|
+
* descriptor parser checks the extension), so no `package.json` above the install directory can make
|
|
22
|
+
* Node read it as CommonJS.
|
|
23
|
+
*
|
|
24
|
+
* Failure isolation: a refused package comes back as a refusal verdict, never thrown. Only a filesystem
|
|
25
|
+
* fault listing the workspace's package directory itself propagates.
|
|
26
|
+
*
|
|
27
|
+
* Architectural role: `features/agent-plugins` capability. Depends only on this feature's own
|
|
28
|
+
* install, activation and path modules; the seams depend on it, nothing here names one.
|
|
29
|
+
*/
|
|
30
|
+
/** A package a seam may read: whose it is and where its files live. */
|
|
2
31
|
export interface TrustedPluginPackage {
|
|
3
32
|
readonly pluginId: string;
|
|
4
33
|
readonly packageRoot: string;
|
|
5
34
|
}
|
|
6
35
|
export interface TrustedPluginPackagesQuery {
|
|
7
36
|
readonly workspaceId: string;
|
|
37
|
+
/** The file a plugin ships at its root to opt in to this seam. */
|
|
8
38
|
readonly filename: string;
|
|
39
|
+
/** Plural noun for refusal text, e.g. `"deploy targets"`: "deploy targets from 'x' were not loaded: …". */
|
|
9
40
|
readonly contribution: string;
|
|
41
|
+
/** Apply the activation gate (see this file's header, gate 1). */
|
|
10
42
|
readonly requireActive: boolean;
|
|
43
|
+
/** Return verdicts sorted by plugin id instead of installed order. */
|
|
11
44
|
readonly orderByPluginId?: (boolean) | undefined;
|
|
45
|
+
/** Hears each package the activation gate skips as switched off, e.g. to read its seam file as
|
|
46
|
+
* data and name the plugin in a refusal. Never called without `requireActive`. */
|
|
12
47
|
readonly onInactive?: ((required: {
|
|
13
48
|
readonly plugin: TrustedPluginPackage;
|
|
14
49
|
}) => Promise<void>) | undefined;
|
|
15
50
|
}
|
|
51
|
+
/** One installed package that ships the seam's file: trusted, or refused with the full refusal line.
|
|
52
|
+
* Returned in installed order (or plugin-id order, see `orderByPluginId`) so a seam that interleaves its own per-package refusals keeps them in
|
|
53
|
+
* the same order as the gate's. */
|
|
16
54
|
export type TrustedPluginVerdict = {
|
|
17
55
|
readonly trusted: TrustedPluginPackage;
|
|
18
56
|
} | {
|
|
@@ -12,6 +12,15 @@ function buildModule(ports) {
|
|
|
12
12
|
const { preferBundledAgentPluginDigests, readBundledAgentPluginDigests } = createBundledDigestsModule(ports);
|
|
13
13
|
const { assertContainedOnDisk } = createPackagePathsModule(ports);
|
|
14
14
|
const { listInstalledPlugins } = createResolveAgentPluginRefsModule(ports);
|
|
15
|
+
/**
|
|
16
|
+
* A verdict for every installed package in this workspace that ships `query.filename`, in
|
|
17
|
+
* installed order (bundled digests preferred when a plugin has more than one installed digest).
|
|
18
|
+
* A switched-off plugin is skipped silently (only `onInactive` hears it); every other refusal is reported.
|
|
19
|
+
*
|
|
20
|
+
* @throws Nothing for a plugin-level fault; only a filesystem fault listing the workspace's package
|
|
21
|
+
* directory itself propagates.
|
|
22
|
+
* @complexity O(p) installed plugins, one activation read each for a plugin that ships the file.
|
|
23
|
+
*/
|
|
15
24
|
async function findTrustedPluginPackages(query) {
|
|
16
25
|
const layout = ports.layout.forWorkspace({ workspaceId: query.workspaceId });
|
|
17
26
|
const bundled = await readBundledAgentPluginDigests(layout.root);
|
|
@@ -32,6 +41,8 @@ function buildModule(ports) {
|
|
|
32
41
|
}
|
|
33
42
|
return verdicts;
|
|
34
43
|
}
|
|
44
|
+
/** Why one installed package may not contribute (`"inactive"` = skip silently), or `null` when it may.
|
|
45
|
+
* @complexity O(1) beyond one activation read. */
|
|
35
46
|
async function trustRefusal(query, plugin, bundledDigest, workspaceRoot) {
|
|
36
47
|
if (query.requireActive) {
|
|
37
48
|
const activation = await resolveAgentPluginActivation(workspaceRoot, plugin.pluginId);
|
|
@@ -45,10 +56,17 @@ function buildModule(ports) {
|
|
|
45
56
|
}
|
|
46
57
|
return null;
|
|
47
58
|
}
|
|
59
|
+
/** Reads one file at a trusted package's root as UTF-8. @complexity O(n) in the file size. */
|
|
48
60
|
async function readTrustedPluginFile(plugin, filename) {
|
|
49
61
|
const absolute = await assertContainedOnDisk(plugin.packageRoot, filename);
|
|
50
62
|
return readFile(absolute, "utf8");
|
|
51
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Imports one module from a trusted package after the containment check. Returns the module's
|
|
66
|
+
* default export, or the refusal reason. The caller validates the export's shape.
|
|
67
|
+
*
|
|
68
|
+
* @complexity One `realpath` walk plus one dynamic import (cached by Node per file URL).
|
|
69
|
+
*/
|
|
52
70
|
async function importContainedModule(plugin, modulePath) {
|
|
53
71
|
let resolved;
|
|
54
72
|
try {
|
|
@@ -6,8 +6,22 @@ export interface InstalledAgentPluginSkill {
|
|
|
6
6
|
readonly skillPath: string;
|
|
7
7
|
}
|
|
8
8
|
export interface InstalledAgentPlugin {
|
|
9
|
+
readonly id?: string | undefined;
|
|
10
|
+
readonly provides?: readonly string[] | undefined;
|
|
9
11
|
readonly pluginId: string;
|
|
10
12
|
readonly version?: (string) | undefined;
|
|
13
|
+
/** the host extension namespace in `plugin.json`: `displayName` — the human-facing title when the id alone reads
|
|
14
|
+
* wrong ("deploy" shows as "Deploy Online"). Lives under the spec's host-extension namespace, not
|
|
15
|
+
* a top-level field, because the spec has no title field and `name` is the stable id that
|
|
16
|
+
* activations, tool ids and ledgers key on — renaming it would churn all of those. Absent means
|
|
17
|
+
* callers title-case the id. */
|
|
18
|
+
readonly displayName?: string | undefined;
|
|
19
|
+
/** the host extension namespace in `plugin.json`: `summary` — a short plain-language explainer for the site owner,
|
|
20
|
+
* shown in the admin in place of the spec `description`. That `description` stays the
|
|
21
|
+
* agent-facing text (search ranks it and returns it to the model), so it can stay dense and
|
|
22
|
+
* technical; this field is never ranked and never reaches the model. Blank lines separate
|
|
23
|
+
* paragraphs. Absent means the admin shows the description. */
|
|
24
|
+
readonly summary?: string | undefined;
|
|
11
25
|
readonly description?: (string) | undefined;
|
|
12
26
|
readonly keywords?: (readonly string[]) | undefined;
|
|
13
27
|
readonly author?: string | PluginManifestAuthor | undefined;
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import type { AgentPluginLayout } from "./layout.js";
|
|
2
2
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
3
|
+
/** The plugin id has no installed package on disk for this workspace. */
|
|
3
4
|
export declare class AgentPluginNotFoundError extends Error {
|
|
4
5
|
constructor({ message }: {
|
|
5
6
|
readonly message: string;
|
|
6
7
|
}, optional?: ErrorOptions);
|
|
7
8
|
}
|
|
9
|
+
/** The plugin is bundled with the host — see this file's header for why that is permanent, not merely
|
|
10
|
+
* "nothing to remove". */
|
|
8
11
|
export declare class AgentPluginNotUninstallableError extends Error {
|
|
9
12
|
constructor({ message }: {
|
|
10
13
|
readonly message: string;
|
|
11
14
|
}, optional?: ErrorOptions);
|
|
12
15
|
}
|
|
16
|
+
/** The installed archives for this plugin id are no longer exactly the ones a confirmed preview listed —
|
|
17
|
+
* see `UninstallAgentPluginOptional.confirmedPreview`. Nothing was removed. */
|
|
13
18
|
export declare class AgentPluginChangedSincePreviewError extends Error {
|
|
14
19
|
constructor({ message }: {
|
|
15
20
|
readonly message: string;
|
|
@@ -21,22 +26,46 @@ export interface UninstallAgentPluginRequired {
|
|
|
21
26
|
readonly pluginId: string;
|
|
22
27
|
}
|
|
23
28
|
export interface UninstallAgentPluginOptional {
|
|
29
|
+
/** The preview a human confirmed. When given, the uninstall refuses, removing nothing, unless the digests it
|
|
30
|
+
* resolves now are exactly this preview's `archiveDigests`: a confirmation is consent to remove what was
|
|
31
|
+
* resolved for the dialog, not whatever the id names by the time the answer arrives (t91 F2.2). */
|
|
24
32
|
readonly confirmedPreview?: (AgentPluginUninstallPreview) | undefined;
|
|
33
|
+
/** Only the browser-confirmed choice may delete persistent state. Off by default. */
|
|
25
34
|
readonly deleteMemory?: boolean | undefined;
|
|
35
|
+
/** INTERNAL — `retire-bundled.ts` only, never an agent tool or HTTP route. Skips the bundled refusal
|
|
36
|
+
* (this file's header, item 2) and nothing else: that refusal exists because a bundled plugin is
|
|
37
|
+
* re-seeded on every boot, which stops being true once the host no longer ships its source
|
|
38
|
+
* (`RETIRED_BUNDLED_AGENT_PLUGINS`). The malformed-entry refusal still applies. */
|
|
26
39
|
readonly retiredBundled?: (boolean) | undefined;
|
|
27
40
|
}
|
|
28
41
|
export interface UninstallAgentPluginResult {
|
|
29
42
|
readonly pluginId: string;
|
|
43
|
+
/** Every installed archive digest removed for this plugin id — normally exactly one. All matching
|
|
44
|
+
* digests are removed: uninstall's job is to make the plugin genuinely gone, so unlike the tool
|
|
45
|
+
* loader, which must pick ONE to serve, there is no "which one" question here. */
|
|
30
46
|
readonly removedDigests: readonly string[];
|
|
31
47
|
}
|
|
48
|
+
/** What an uninstall of one plugin would remove, in a form safe to show a human: no host path. */
|
|
32
49
|
export interface AgentPluginUninstallPreview {
|
|
33
50
|
readonly pluginId: string;
|
|
51
|
+
/** Distinct manifest versions across the matching digests; empty when none declares one. */
|
|
34
52
|
readonly versions: readonly string[];
|
|
35
53
|
readonly archiveDigests: readonly string[];
|
|
36
54
|
}
|
|
55
|
+
/** A reversible staged package and its original permissions; shared by uninstall and replacement. */
|
|
56
|
+
/** One package root renamed aside, and what restoring it takes: the path it must go back to, and the
|
|
57
|
+
* mode it carried before staging unfroze its root directory. */
|
|
58
|
+
export interface StagedTree {
|
|
59
|
+
readonly quarantined: string;
|
|
60
|
+
readonly original: string;
|
|
61
|
+
readonly originalMode: number;
|
|
62
|
+
}
|
|
37
63
|
/** Internal composition; one state/queue instance per injected host context. */
|
|
38
64
|
export declare function createUninstallModule(ports: AgentPluginLifecyclePorts): {
|
|
39
65
|
previewAgentPluginUninstall: (required: UninstallAgentPluginRequired) => Promise<AgentPluginUninstallPreview>;
|
|
40
66
|
uninstallAgentPlugin: (required: UninstallAgentPluginRequired, optional?: UninstallAgentPluginOptional) => Promise<UninstallAgentPluginResult>;
|
|
67
|
+
stageForRemoval: (packagesDir: string, packageRoot: string) => Promise<StagedTree>;
|
|
68
|
+
restoreStagedTrees: (staged: readonly StagedTree[], cause: unknown) => Promise<unknown>;
|
|
69
|
+
removeFrozenPackageTree: (root: string) => Promise<void>;
|
|
41
70
|
};
|
|
42
71
|
//# sourceMappingURL=uninstall.d.ts.map
|