@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,5 +1,4 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Historical lifecycle rationale, carried with the implementation during consumer adoption.
|
|
3
2
|
* @file `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` — the Agent Plugins v1.0.0
|
|
4
3
|
* `plugin.json` / `mcp.json` grammar (agent-plugins.org/specification), verified against the live
|
|
5
4
|
* spec rather than inferred from an example manifest.
|
|
@@ -11,8 +10,7 @@
|
|
|
11
10
|
* shares none of those fields and defines no execution/trust/capability model at all — see this
|
|
12
11
|
* feature's own header comment in `install.ts` for why the two formats cannot be unified.
|
|
13
12
|
*
|
|
14
|
-
*
|
|
15
|
-
* 2026-08-12, not carried forward from an earlier round's inference):
|
|
13
|
+
* Pinned v1.0.0 rules from agent-plugins.org/specification:
|
|
16
14
|
* - `plugin.json`'s `name`: 1-64 chars, lowercase alphanumeric/hyphen/period only, must start and
|
|
17
15
|
* end alphanumeric, no `--` or `..` consecutive delimiters (§5.5). Valid: `my-plugin`,
|
|
18
16
|
* `acme.tools`, `lint3r`, `a`. Invalid: `My-Plugin`, `-start`, `has--double`.
|
|
@@ -23,12 +21,8 @@
|
|
|
23
21
|
* returns them as `warnings`, never as a rejection reason.
|
|
24
22
|
* - `mcp.json`'s top-level shape is `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }` —
|
|
25
23
|
* `mcpServers` MUST be an object whose member names identify servers.
|
|
26
|
-
* - Each server entry MUST carry a `type` discriminator matching
|
|
27
|
-
*
|
|
28
|
-
* `/specification`, 2026-09-10 — superseding this file's earlier "not validated, no caller needs
|
|
29
|
-
* it" note, which is now false: `capability-projection.ts`'s MCP-server descriptors are no longer
|
|
30
|
-
* unconditionally `execute: { kind: "unavailable" }`, and launching a server needs its real
|
|
31
|
-
* transport config, not just its id):
|
|
24
|
+
* - Each server entry MUST carry a `type` discriminator matching one of three CLOSED variants.
|
|
25
|
+
* Launching requires the actual transport config, not just a server id:
|
|
32
26
|
* - `stdio`: requires `command` (non-empty string); optional `args` (string[]), `env`
|
|
33
27
|
* (string-valued object — MUST NOT set `PLUGIN_ROOT`/`PLUGIN_DATA`, which the spec reserves),
|
|
34
28
|
* `cwd` (plugin-relative path).
|
|
@@ -58,11 +52,11 @@
|
|
|
58
52
|
* own "receives a JS value, never a file path or raw bytes" contract. `install.ts` is the only
|
|
59
53
|
* caller that reads the file and hands the parsed value here.
|
|
60
54
|
*/
|
|
55
|
+
import { readPluginIdentity, validatePluginIdentity } from "@jini-ai/core/plugin-directory";
|
|
61
56
|
export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
|
|
62
57
|
/**
|
|
63
58
|
* The one plugin.json schema version this loader understands. A manifest declaring any other
|
|
64
|
-
* value is rejected outright
|
|
65
|
-
* v1 and must survive spec churn") rather than guessed at.
|
|
59
|
+
* value is rejected outright rather than guessed at: pinning the grammar must survive spec churn.
|
|
66
60
|
*/
|
|
67
61
|
const PLUGIN_SCHEMA_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
68
62
|
const MCP_SCHEMA_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
|
|
@@ -91,13 +85,6 @@ function isJsonObject(value) {
|
|
|
91
85
|
function coerceOptionalString(value) {
|
|
92
86
|
return typeof value === "string" ? value : undefined;
|
|
93
87
|
}
|
|
94
|
-
/**
|
|
95
|
-
* `value` narrowed to a `string[]` (non-string entries dropped), or `undefined` when `value`
|
|
96
|
-
* itself isn't an array — `keywords`'s own coercion.
|
|
97
|
-
*/
|
|
98
|
-
function coerceStringArray(value) {
|
|
99
|
-
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : undefined;
|
|
100
|
-
}
|
|
101
88
|
function parseManifestAuthor(value) {
|
|
102
89
|
if (typeof value === 'string')
|
|
103
90
|
return value; // Existing installed manifests may carry a string.
|
|
@@ -134,7 +121,7 @@ function resolveManifestName(raw) {
|
|
|
134
121
|
* `plugin-runtime/discovery.ts`).
|
|
135
122
|
* @complexity O(k + n) time and space in the input keys and keyword entries.
|
|
136
123
|
*/
|
|
137
|
-
export function parseAgentPluginManifest({ value }) {
|
|
124
|
+
export function parseAgentPluginManifest({ value }, { identityNamespace = "tovu" } = {}) {
|
|
138
125
|
if (!isJsonObject(value))
|
|
139
126
|
return { ok: false, errors: ["plugin.json must be a JSON object"] };
|
|
140
127
|
const raw = value;
|
|
@@ -152,15 +139,20 @@ export function parseAgentPluginManifest({ value }) {
|
|
|
152
139
|
if (!KNOWN_MANIFEST_KEYS.has(key))
|
|
153
140
|
warnings.push(`unrecognized plugin.json field '${key}' (ignored per spec)`);
|
|
154
141
|
}
|
|
142
|
+
errors.push(...validatePluginIdentity({ manifest: raw, namespace: identityNamespace }).errors);
|
|
155
143
|
if (errors.length > 0)
|
|
156
144
|
return { ok: false, errors };
|
|
145
|
+
const identity = readPluginIdentity({ manifest: raw, namespace: identityNamespace });
|
|
146
|
+
warnings.push(...identity.warnings);
|
|
157
147
|
const manifest = {
|
|
158
148
|
name: nameResult.name,
|
|
149
|
+
id: identity.id,
|
|
150
|
+
provides: identity.provides,
|
|
159
151
|
version: coerceOptionalString(raw.version),
|
|
160
152
|
description: coerceOptionalString(raw.description),
|
|
161
153
|
author: parseManifestAuthor(raw.author),
|
|
162
154
|
license: coerceOptionalString(raw.license),
|
|
163
|
-
keywords:
|
|
155
|
+
keywords: raw.keywords === undefined ? undefined : identity.keywords,
|
|
164
156
|
...(isJsonObject(raw.extensions) ? { extensions: raw.extensions } : {}),
|
|
165
157
|
};
|
|
166
158
|
return { ok: true, manifest, warnings };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
2
|
+
/** Raised for any path a package entry (or a later read against an already-installed package) is
|
|
3
|
+
* not allowed to reach — lexically invalid, or resolving outside the package root. */
|
|
2
4
|
export declare class PackagePathViolation extends Error {
|
|
3
5
|
constructor({ message }: {
|
|
4
6
|
readonly message: string;
|
|
@@ -1,10 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The one path-safety primitive every Agent Plugin package read goes through — extraction
|
|
3
|
+
* (`install.ts`) today, any future preview/read path tomorrow. One implementation, not two, so the
|
|
4
|
+
* containment guarantee cannot drift between "how a package is written" and "how it is later read".
|
|
5
|
+
*
|
|
6
|
+
* The rule is not invented here. The Agent Plugins spec (v1.0.0, "Path safety") states it as a MUST:
|
|
7
|
+
* "the filesystem-resolved path MUST remain within the filesystem-resolved plugin root... symlinks,
|
|
8
|
+
* junctions, reparse points, and equivalent filesystem mechanisms MAY resolve to targets within the
|
|
9
|
+
* plugin root, but clients MUST reject package paths that resolve outside it." The word
|
|
10
|
+
* "filesystem-resolved" is exactly what a lexical `../`-string check alone does not satisfy — a
|
|
11
|
+
* hostile archive can plant a symlinked directory at a lexically-clean path and let a later,
|
|
12
|
+
* innocent-looking entry walk through it (the two-step zip-slip vector; see `install.ts`'s header
|
|
13
|
+
* for the extraction-time mitigation, which is to refuse symlink ENTRIES outright rather than ever
|
|
14
|
+
* relying on this function to catch one after the fact).
|
|
15
|
+
*
|
|
16
|
+
* Architectural role:
|
|
17
|
+
* Pure path logic plus the minimum filesystem calls (`realpath`) the spec's own wording requires.
|
|
18
|
+
* No policy about what happens after containment is established — that is every caller's own job.
|
|
19
|
+
*/
|
|
1
20
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
21
|
import path from "node:path";
|
|
22
|
+
/** Raised for any path a package entry (or a later read against an already-installed package) is
|
|
23
|
+
* not allowed to reach — lexically invalid, or resolving outside the package root. */
|
|
3
24
|
export class PackagePathViolation extends Error {
|
|
4
25
|
constructor({ message }, optional = {}) { super(message, optional); }
|
|
5
26
|
}
|
|
6
27
|
function buildModule(ports) {
|
|
7
28
|
const realpath = ports.filesystem.realpath.bind(ports.filesystem);
|
|
29
|
+
/**
|
|
30
|
+
* Rejects an absolute path, a NUL byte, a Windows drive prefix, or a lexical `..`/`.` segment —
|
|
31
|
+
* BEFORE any filesystem call. This is the cheap majority of attempts; {@link assertContainedOnDisk}
|
|
32
|
+
* catches the rest (an entry that is lexically clean but is, or passes through, a symlink).
|
|
33
|
+
*
|
|
34
|
+
* Backslashes are normalized to `/` rather than rejected: the spec models paths as POSIX-style
|
|
35
|
+
* (`./`-prefixed), and an archive built on Windows may use them as its own separator without any
|
|
36
|
+
* hostile intent — the traversal segments that actually matter (`..`, an absolute prefix) are
|
|
37
|
+
* checked after normalization either way.
|
|
38
|
+
*
|
|
39
|
+
* @throws {PackagePathViolation} If the path is unsafe by construction, independent of any
|
|
40
|
+
* filesystem state.
|
|
41
|
+
* @complexity O(n) in the path's own length.
|
|
42
|
+
*/
|
|
8
43
|
function normalizePackageEntryPath(rawEntryPath) {
|
|
9
44
|
if (rawEntryPath.includes("\0")) {
|
|
10
45
|
throw new PackagePathViolation({ message: `package entry path contains a NUL byte: '${rawEntryPath}'` });
|
|
@@ -19,6 +54,30 @@ function buildModule(ports) {
|
|
|
19
54
|
}
|
|
20
55
|
return normalized;
|
|
21
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolves `entryPath` against `packageRoot` and asserts the REAL, symlink-resolved result is still
|
|
59
|
+
* inside the REAL, symlink-resolved root — the spec's "filesystem-resolved" requirement, not a
|
|
60
|
+
* string comparison.
|
|
61
|
+
*
|
|
62
|
+
* `packageRoot` is `realpath`'d fresh on every call rather than accepted pre-resolved from the
|
|
63
|
+
* caller, so a root whose own directory entry is later replaced by a symlink between two calls
|
|
64
|
+
* cannot smuggle a stale trusted root in.
|
|
65
|
+
*
|
|
66
|
+
* Handles the case extraction needs and a plain read doesn't: the target may not exist yet (mid-
|
|
67
|
+
* extraction, before this entry's own write), in which case the deepest EXISTING ancestor is
|
|
68
|
+
* `realpath`'d instead — so a symlinked PARENT directory planted by an earlier archive entry is
|
|
69
|
+
* still caught even though the leaf hasn't been created yet.
|
|
70
|
+
*
|
|
71
|
+
* @param packageRoot - Absolute path to the package root (extraction destination, or an already-
|
|
72
|
+
* installed package's root).
|
|
73
|
+
* @param entryPath - A package-relative path, as found in an archive entry name or a caller's own
|
|
74
|
+
* read request. Lexically validated internally via {@link normalizePackageEntryPath}.
|
|
75
|
+
* @returns The resolved (lexical, not yet realpath'd) absolute destination — safe for the caller to
|
|
76
|
+
* `open`/`mkdir`/`stat` against.
|
|
77
|
+
* @throws {PackagePathViolation} If the path is lexically unsafe, or the filesystem-resolved result
|
|
78
|
+
* (direct or via a symlinked ancestor) falls outside `packageRoot`.
|
|
79
|
+
* @complexity O(d) in path depth — one `realpath` per unresolved ancestor in the worst case.
|
|
80
|
+
*/
|
|
22
81
|
async function assertContainedOnDisk(packageRoot, entryPath) {
|
|
23
82
|
const normalized = normalizePackageEntryPath(entryPath);
|
|
24
83
|
const realRoot = await realpath(packageRoot);
|
|
@@ -42,6 +101,9 @@ function buildModule(ports) {
|
|
|
42
101
|
const relative = path.relative(root, candidate);
|
|
43
102
|
return relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`));
|
|
44
103
|
}
|
|
104
|
+
/** Walks up from `candidate` until an existing ancestor is found, `realpath`s that ancestor, and
|
|
105
|
+
* reattaches the non-existent tail — so a symlinked ancestor is caught even for a path whose leaf
|
|
106
|
+
* (and possibly several parent segments) have not been created yet. */
|
|
45
107
|
async function realpathDeepestExistingAncestor(candidate) {
|
|
46
108
|
let current = path.dirname(candidate);
|
|
47
109
|
const tail = [path.basename(candidate)];
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { AgentPluginLifecyclePorts } from './ports.js';
|
|
2
2
|
/** Reuses the platform lock with injected effects, never age-stealing a living migration. */
|
|
3
3
|
export declare function createPersistentStateModule(ports: AgentPluginLifecyclePorts, _optional?: Record<string, never>): {
|
|
4
|
+
withIdentityLock: <T>(required: {
|
|
5
|
+
workspaceRoot: string;
|
|
6
|
+
run(): Promise<T>;
|
|
7
|
+
}, _optional?: {}) => Promise<T>;
|
|
4
8
|
withPluginLock: <T>(required: {
|
|
5
9
|
workspaceRoot: string;
|
|
6
10
|
pluginId: string;
|
|
@@ -9,7 +13,7 @@ export declare function createPersistentStateModule(ports: AgentPluginLifecycleP
|
|
|
9
13
|
memory: (required: {
|
|
10
14
|
workspaceRoot: string;
|
|
11
15
|
pluginId: string;
|
|
12
|
-
}, optional?: {}) => import("../../persistent-state.js").PluginMemory;
|
|
16
|
+
}, optional?: {}) => import("../../core/persistent-state.js").PluginMemory;
|
|
13
17
|
assertOwnedPath: (required: {
|
|
14
18
|
workspaceRoot: string;
|
|
15
19
|
entryPath: string;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../persistent-state.js';
|
|
1
|
+
import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../core/persistent-state.js';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { withFileLock } from '@jini-ai/platform/fs/file-lock';
|
|
3
4
|
import { createPackagePathsModule } from './package-paths.js';
|
|
4
5
|
import { parseAgentPluginManifest } from './manifest.js';
|
|
@@ -16,6 +17,7 @@ export function createPersistentStateModule(ports, _optional = {}) {
|
|
|
16
17
|
}),
|
|
17
18
|
};
|
|
18
19
|
return {
|
|
20
|
+
withIdentityLock: (required, _optional = {}) => effects.withLock({ lockPath: path.join(required.workspaceRoot, '.publisher-identity.lock'), run: required.run }),
|
|
19
21
|
withPluginLock: (required, optional = {}) => withPluginStateLock({ ...effects, ...required }, optional),
|
|
20
22
|
memory: (required, optional = {}) => createPluginMemory({ ...effects, ...required }, optional),
|
|
21
23
|
assertOwnedPath: (required, optional = {}) => assertPluginStatePath({ ...effects, ...required }, optional),
|
|
@@ -52,6 +52,12 @@ export interface AgentPluginOutboundGuardPort {
|
|
|
52
52
|
}): Promise<void>;
|
|
53
53
|
}
|
|
54
54
|
export interface AgentPluginLifecyclePorts {
|
|
55
|
+
/** Optional host admission serializes cross-kind identity checking and publication. */
|
|
56
|
+
readonly identityAdmission?: (<T>(required: {
|
|
57
|
+
id: string;
|
|
58
|
+
localId: string;
|
|
59
|
+
run: () => Promise<T>;
|
|
60
|
+
}, optional?: Record<string, never>) => Promise<T>) | undefined;
|
|
55
61
|
readonly onEvent?: ((required: {
|
|
56
62
|
readonly event: string;
|
|
57
63
|
readonly message: string;
|
|
@@ -69,6 +75,18 @@ export interface AgentPluginLifecyclePorts {
|
|
|
69
75
|
readonly layout: AgentPluginLayoutPort;
|
|
70
76
|
readonly productName: string;
|
|
71
77
|
readonly extensionNamespace: string;
|
|
78
|
+
/** Host boot policy may migrate every workspace before seeding the requested workspace.
|
|
79
|
+
* A failure refuses all publication; absent uses the owner's single-workspace migration. */
|
|
80
|
+
readonly migrateBeforeSeed?: ((required: {
|
|
81
|
+
readonly layout: AgentPluginLayoutPort;
|
|
82
|
+
readonly workspaceId: string;
|
|
83
|
+
}, optional: Record<string, never>) => Promise<{
|
|
84
|
+
readonly complete: boolean;
|
|
85
|
+
}>) | undefined;
|
|
86
|
+
/** Host-facing recovery instructions; the bundled refusal and its security gate remain owned here. */
|
|
87
|
+
readonly formatBundledUninstallRecovery?: ((required: {
|
|
88
|
+
readonly pluginId: string;
|
|
89
|
+
}, optional: Record<string, never>) => string) | undefined;
|
|
72
90
|
readonly readServerMetadata?: AgentPluginServerMetadataReader | undefined;
|
|
73
91
|
readonly deliveryMode: AgentPluginDeliveryMode;
|
|
74
92
|
readonly mcpProvisioning: AgentPluginMcpProvisioningPort;
|
|
@@ -76,6 +94,6 @@ export interface AgentPluginLifecyclePorts {
|
|
|
76
94
|
readonly outboundGuard: AgentPluginOutboundGuardPort;
|
|
77
95
|
}
|
|
78
96
|
/** Application policy/effects are required; observers and metadata translators are optional. */
|
|
79
|
-
export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata'>;
|
|
80
|
-
export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata'>;
|
|
97
|
+
export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery' | 'identityAdmission'>;
|
|
98
|
+
export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery' | 'identityAdmission'>;
|
|
81
99
|
//# sourceMappingURL=ports.d.ts.map
|
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { /**
|
|
2
|
+
* How a pinned ref's content reaches the agent.
|
|
3
|
+
*
|
|
4
|
+
* - `inject` — the whole eponymous SKILL.md (~15KB for `ui-ux-design`) plus every other package file
|
|
5
|
+
* listed by absolute path, in the run's prompt prefix. Today's shipped behaviour, and the default.
|
|
6
|
+
* - `pointer` — ~400 bytes naming the exact tool call that returns that SAME SKILL.md, and nothing
|
|
7
|
+
* else. The content is identical; only the delivery differs, which is what makes the two a
|
|
8
|
+
* controlled comparison rather than two different experiments.
|
|
9
|
+
*
|
|
10
|
+
* Selected per-process by the host-supplied `deliveryMode` port. This is a MEASUREMENT AFFORDANCE, not a
|
|
11
|
+
* migration flag: pointer and inject must coexist to compare delivery of identical content.
|
|
12
|
+
* Selecting an unconditional mode requires the measurement result.
|
|
13
|
+
*/ AgentPluginDeliveryMode, InstalledAgentPlugin } from "./types.js";
|
|
2
14
|
import type { AgentPluginWorkspaceLayout } from "./layout.js";
|
|
3
15
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
4
16
|
export type { AgentPluginDeliveryMode } from "./types.js";
|
|
@@ -1,24 +1,105 @@
|
|
|
1
|
+
import { listInstalledPackageRoots, INSTALLED_PLUGIN_DIGEST_PATTERN } from "./installed-package-roots.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file Resolves a run's pinned Agent Plugin refs (`pluginRefIds` — `run-start-context.ts`'s
|
|
4
|
+
* decoded `contextRef` field, itself sourced from the composer's `pluginRefId` chips,
|
|
5
|
+
* `composer-capabilities.ts`'s own doc) into the real prompt-prefix text `agent-daemon-server.ts`'s
|
|
6
|
+
* `onStarted` prepends to the run's prompt.
|
|
7
|
+
*
|
|
8
|
+
* ---------------------------------------------------------------------------
|
|
9
|
+
* The activation-record gap this function works around (KNOWN GAP, not silently papered over)
|
|
10
|
+
* ---------------------------------------------------------------------------
|
|
11
|
+
* `activation.ts` owns the per-workspace enabled decision; a disabled or undetermined ref is
|
|
12
|
+
* refused before resolution.
|
|
13
|
+
* What that record deliberately does NOT carry is which installed DIGEST is current for a plugin id.
|
|
14
|
+
* For a BUNDLED plugin, `bundled-digests.ts`'s ledger answers that: the boot seeder
|
|
15
|
+
* records which digest the running build published, so a workspace holding an upgraded plugin's old
|
|
16
|
+
* and new package is no longer ambiguous — the build's own record names the winner. For every other
|
|
17
|
+
* plugin id, closing this needs the operator-facing upgrade/pin flow (and, alongside it, the
|
|
18
|
+
* admission gate for MCP servers `capability-projection.ts`'s own header names) and remains a
|
|
19
|
+
* separate, later decision.
|
|
20
|
+
*
|
|
21
|
+
* So this function still scans every digest this workspace has ever installed under
|
|
22
|
+
* `packages/sha256/*` and matches on the installed `plugin.json`'s own `name` field, after dropping
|
|
23
|
+
* any digest of a bundled plugin the ledger records as superseded:
|
|
24
|
+
*
|
|
25
|
+
* - Exactly one match: resolves normally.
|
|
26
|
+
* - Zero matches: fails with a "not installed" reason — the operator pinned a chip for a plugin
|
|
27
|
+
* that is not (or no longer) actually on disk for this workspace.
|
|
28
|
+
* - More than one match: fails with an explicit ambiguity reason naming every matching digest.
|
|
29
|
+
* Silently picking one (e.g. the lexicographically-last, or "the newest") would be a WORSE
|
|
30
|
+
* failure mode than an explicit error — a wrong plugin's content would reach the agent, and
|
|
31
|
+
* nothing about the run would look wrong until someone noticed the agent's advice didn't match
|
|
32
|
+
* what was supposedly pinned. An explicit error is loud and immediately actionable instead. That
|
|
33
|
+
* refusal is UNCHANGED and still the default: the ledger narrows a plugin id only when the build
|
|
34
|
+
* itself published one of the installed digests, which is authority rather than a guess, and it
|
|
35
|
+
* has no say at all over packages an operator installed (`bundled-digests.ts`, consequence 1).
|
|
36
|
+
*
|
|
37
|
+
* ---------------------------------------------------------------------------
|
|
38
|
+
* What gets injected, and why not the whole 280K package
|
|
39
|
+
* ---------------------------------------------------------------------------
|
|
40
|
+
* Only the resolved plugin's own eponymous skill — `skills/<pluginRefId>/SKILL.md` (the skill
|
|
41
|
+
* folder whose name equals the plugin's own id; for `ui-ux-design` specifically, this is real,
|
|
42
|
+
* ~5.5KB content, not all seven of that plugin's skill folders). The reasoning mirrors
|
|
43
|
+
* `capability-projection.ts`'s own "Skills get a real executable binding" rule: a Skill's markdown
|
|
44
|
+
* IS the plugin's real, agent-facing content, while the rest of the package (references,
|
|
45
|
+
* examples, scripts) is supporting material a plugin's own SKILL.md is written to point an agent
|
|
46
|
+
* at when it needs more depth (the Agent Plugins spec's own convention every skill in this package
|
|
47
|
+
* follows). Injecting the FULL package indiscriminately would balloon every run's prompt with
|
|
48
|
+
* pages this specific task usually never needs. Every OTHER file in the resolved package is still
|
|
49
|
+
* listed by absolute path — genuinely `Read`-able by the spawned CLI agent, which runs with real
|
|
50
|
+
* filesystem access unlike a browser-facing preview — so nothing is unreachable, only deferred to
|
|
51
|
+
* an explicit follow-up read.
|
|
52
|
+
*
|
|
53
|
+
* Architectural role:
|
|
54
|
+
* Server-side only (reads `node:fs/promises`, imports `install.ts`'s `indexInstalledRoot`) — never
|
|
55
|
+
* imported into a browser bundle. Returns a discriminated result rather than throwing, so
|
|
56
|
+
* `agent-daemon-server.ts`'s `onStarted` gets one place to branch on success/failure without a
|
|
57
|
+
* second try/catch layer duplicating this module's own error classification.
|
|
58
|
+
*/
|
|
1
59
|
import { createPersistentStateModule } from "./persistent-state.js";
|
|
2
|
-
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
3
60
|
import path from "node:path";
|
|
4
61
|
import { createPackagePathsModule } from "./package-paths.js";
|
|
5
|
-
import {} from "./activation.js";
|
|
6
62
|
import { createActivationModule } from "./activation.js";
|
|
7
|
-
import {} from "./bundled-digests.js";
|
|
8
63
|
import { createBundledDigestsModule } from "./bundled-digests.js";
|
|
9
64
|
import { createInstallModule } from "./install.js";
|
|
10
65
|
import { createCapabilityProjectionModule } from "./capability-projection.js";
|
|
11
66
|
function buildModule(ports) {
|
|
12
67
|
const persistentState = createPersistentStateModule(ports);
|
|
13
68
|
const { assertContainedOnDisk } = createPackagePathsModule(ports);
|
|
14
|
-
const readdir = ports.filesystem.readdir.bind(ports.filesystem);
|
|
15
69
|
const stat = ports.filesystem.stat.bind(ports.filesystem);
|
|
16
70
|
const { resolveAgentPluginActivation } = createActivationModule(ports);
|
|
17
71
|
const { preferBundledAgentPluginDigests, readBundledAgentPluginDigests } = createBundledDigestsModule(ports);
|
|
18
72
|
const { indexInstalledRoot } = createInstallModule(ports);
|
|
19
73
|
const { readInstalledSkillMarkdown } = createCapabilityProjectionModule(ports);
|
|
20
|
-
|
|
74
|
+
/** Reads the delivery mode for this process. An unset or unrecognised value is `inject` — an A/B
|
|
75
|
+
* affordance must never be able to change production behaviour by typo. */
|
|
21
76
|
function resolveAgentPluginDeliveryMode() { return ports.deliveryMode; }
|
|
77
|
+
/**
|
|
78
|
+
* Resolves every pinned `pluginRefId` into real on-disk text and joins the results into one
|
|
79
|
+
* prompt-prefix block, in the order the refs were pinned.
|
|
80
|
+
*
|
|
81
|
+
* @param pluginRefIds - This run's pinned Agent Plugin ids (`run-start-context.ts`'s decoded
|
|
82
|
+
* `pluginRefIds` — already filtered to non-empty strings by that point). An empty array resolves
|
|
83
|
+
* to an empty prefix with no filesystem access at all — the common case (no plugin pinned).
|
|
84
|
+
* @param workspaceLayout - This run's own workspace's Agent Plugin layout
|
|
85
|
+
* (`resolveAgentPluginLayout().forWorkspace(workspaceId)`), never a shared/instance-level one —
|
|
86
|
+
* see `layout.ts`'s own tenant-isolation header for why a workspace's installed packages must
|
|
87
|
+
* never be resolved against another workspace's tree.
|
|
88
|
+
* @returns `{ ok: true, promptPrefix }` once every ref resolves, or the FIRST `{ ok: false, reason }`
|
|
89
|
+
* encountered — a run pinning two refs where the second is ambiguous still fails clearly, rather
|
|
90
|
+
* than partially augmenting the prompt with only the first ref's content.
|
|
91
|
+
* @complexity O(r * d) where r is `pluginRefIds.length` and d is the number of installed digests in
|
|
92
|
+
* this workspace — each ref independently re-scans the (typically small) digest list, since a
|
|
93
|
+
* different ref may resolve to a different digest.
|
|
94
|
+
*/
|
|
95
|
+
// Read ONCE for the whole call, not per ref: it is one small file, and every ref resolves against
|
|
96
|
+
// the same workspace's ledger. See `bundled-digests.ts` for why this can only ever RESOLVE an
|
|
97
|
+
// ambiguity below, never create one.
|
|
98
|
+
// See activation.ts for the shared activation rule. One small file read per pinned ref; the
|
|
99
|
+
// fail-CLOSED `resolveAgentPluginActivation` (the same reader the per-call tool gate uses)
|
|
100
|
+
// refuses on a corrupt/unreadable file instead of reading it as "nothing recorded" — injecting a
|
|
101
|
+
// plugin's guidance SPENDS it exactly like a tool call, so this surface must refuse rather than
|
|
102
|
+
// read a fault as consent.
|
|
22
103
|
async function resolveAgentPluginRefs(pluginRefIds, workspaceLayout, deliveryMode = resolveAgentPluginDeliveryMode()) {
|
|
23
104
|
if (pluginRefIds.length === 0)
|
|
24
105
|
return { ok: true, promptPrefix: "" };
|
|
@@ -35,6 +116,12 @@ function buildModule(ports) {
|
|
|
35
116
|
}
|
|
36
117
|
return { ok: true, promptPrefix: sections.join("\n\n") };
|
|
37
118
|
}
|
|
119
|
+
/** The run-start refusal for one ref's activation verdict, or `undefined` when it may be injected.
|
|
120
|
+
* @complexity O(1). */
|
|
121
|
+
// A distinct reason from "not installed", because the operator's remedy is different: the bytes
|
|
122
|
+
// ARE here and the fix is to enable the plugin, not to install it. Telling them to install
|
|
123
|
+
// something already present is the kind of wrong-but-plausible error message that costs an
|
|
124
|
+
// afternoon.
|
|
38
125
|
function activationRefusal(pluginRefId, verdict) {
|
|
39
126
|
if (verdict.verdict === "active")
|
|
40
127
|
return undefined;
|
|
@@ -45,47 +132,57 @@ function buildModule(ports) {
|
|
|
45
132
|
return (`Agent Plugin '${pluginRefId}' is installed in this workspace but is not enabled — it ships with ${ports.productName} and ` +
|
|
46
133
|
"stays inactive until an operator turns it on. Enable it before pinning it to a run.");
|
|
47
134
|
}
|
|
135
|
+
/** Every installed digest's `InstalledAgentPlugin`, indexed once per call to
|
|
136
|
+
* {@link resolveAgentPluginRefs} — one `readdir` plus one `indexInstalledRoot` walk per digest.
|
|
137
|
+
* Digest directory names that do not match the expected 64-hex-character shape are skipped rather
|
|
138
|
+
* than passed to `indexInstalledRoot` — `packages/sha256/` is not asserted empty of anything else
|
|
139
|
+
* a future tool might place there, and a non-digest entry is not this function's to interpret.
|
|
140
|
+
*
|
|
141
|
+
* Exported for a second caller outside this module: `tool-registrations.ts`'s
|
|
142
|
+
* `loadInstalledAgentPluginToolSources` needs the SAME per-digest walk (every installed digest,
|
|
143
|
+
* whichever skills each one carries) to build one `agent_plugin_<pluginId>` tool per installed
|
|
144
|
+
* plugin — the exact shape this function already builds, just consumed differently than
|
|
145
|
+
* `resolveOnePluginRef`'s own "resolve one pinned ref" use below. Reusing this rather than
|
|
146
|
+
* re-walking `packages/sha256/*` a second, less-validated way keeps the digest-directory-name check
|
|
147
|
+
* and the per-digest failure isolation in exactly one place. */
|
|
48
148
|
async function listInstalledPlugins(workspaceRoot) {
|
|
49
|
-
let folders;
|
|
50
|
-
try {
|
|
51
|
-
folders = await readdir(workspaceRoot, { withFileTypes: true });
|
|
52
|
-
}
|
|
53
|
-
catch (error) {
|
|
54
|
-
if (isEnoent(error))
|
|
55
|
-
return [];
|
|
56
|
-
throw error;
|
|
57
|
-
}
|
|
58
149
|
const installed = [];
|
|
59
|
-
for (const
|
|
60
|
-
if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
|
|
61
|
-
continue;
|
|
62
|
-
const packagesDir = path.join(workspaceRoot, folder.name, "package", "sha256");
|
|
63
|
-
let entries;
|
|
150
|
+
for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
|
|
64
151
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
152
|
+
const plugin = await indexInstalledRoot(root.packageRoot, root.archiveDigest);
|
|
153
|
+
if (plugin.pluginId === root.localId)
|
|
154
|
+
installed.push(plugin);
|
|
67
155
|
}
|
|
68
156
|
catch {
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
for (const entry of entries) {
|
|
72
|
-
if (!entry.isDirectory() || !SHA256_DIGEST_DIRNAME_PATTERN.test(entry.name))
|
|
73
|
-
continue;
|
|
74
|
-
try {
|
|
75
|
-
const packageRoot = await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, path.join(packagesDir, entry.name)));
|
|
76
|
-
const plugin = await indexInstalledRoot(packageRoot, entry.name);
|
|
77
|
-
if (plugin.pluginId === folder.name)
|
|
78
|
-
installed.push(plugin);
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
// One corrupt package never hides another plugin. Memory-only folders are not installs.
|
|
82
|
-
}
|
|
157
|
+
// One corrupt package never hides another plugin. Memory-only folders are not installs.
|
|
83
158
|
}
|
|
84
159
|
}
|
|
85
160
|
return installed;
|
|
86
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Whether one installed archive digest still has its package directory under `packagesDir` — the
|
|
164
|
+
* cheap, per-call "is this still installed" signal `tool-registrations.ts`'s tool gate needs.
|
|
165
|
+
*
|
|
166
|
+
* Lives here, beside {@link listInstalledPlugins}, because it must agree with that walk about two
|
|
167
|
+
* things a second implementation would eventually get wrong: that an installed package root IS
|
|
168
|
+
* `<packagesDir>/<digest>` and nothing else, and that a directory name is only a digest when it
|
|
169
|
+
* matches the 64-hex shape. It deliberately does NOT re-index the package (`indexInstalledRoot`
|
|
170
|
+
* reads and validates a manifest, which is far too much per tool call): directory presence is
|
|
171
|
+
* exactly what `uninstall.ts` removes, and it removes it by `rename` BEFORE it touches the
|
|
172
|
+
* activation record, so the gate observes a removal at least as early as it observes the record's
|
|
173
|
+
* deletion, never later.
|
|
174
|
+
*
|
|
175
|
+
* Follows symlinks (plain `stat`, not `lstat`) on purpose — `listInstalledPlugins` does too, so a
|
|
176
|
+
* deployment whose digest directory is a symlink registers a tool that this would otherwise refuse
|
|
177
|
+
* to authorize.
|
|
178
|
+
*
|
|
179
|
+
* @returns `false` for a digest that does not match the installed-directory grammar, for a missing
|
|
180
|
+
* directory, for a non-directory at that path, and for ANY filesystem fault — every answer this
|
|
181
|
+
* cannot establish positively is `false`, because its one caller uses it to authorize.
|
|
182
|
+
* @complexity One `stat`.
|
|
183
|
+
*/
|
|
87
184
|
async function isInstalledDigestPresent(packagesDir, archiveDigest) {
|
|
88
|
-
if (!
|
|
185
|
+
if (!INSTALLED_PLUGIN_DIGEST_PATTERN.test(archiveDigest))
|
|
89
186
|
return false;
|
|
90
187
|
try {
|
|
91
188
|
return (await stat(path.join(packagesDir, archiveDigest))).isDirectory();
|
|
@@ -94,9 +191,16 @@ function buildModule(ports) {
|
|
|
94
191
|
return false;
|
|
95
192
|
}
|
|
96
193
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Resolves one `pluginRefId`, applying the zero/one/many-match rules described in this module's
|
|
196
|
+
* own header.
|
|
197
|
+
*/
|
|
198
|
+
// Superseded installs of a BUNDLED plugin are dropped here — by the build's own recorded answer,
|
|
199
|
+
// never by picking one. Every id the ledger has no authority over reaches the many-match refusal
|
|
200
|
+
// below exactly as it always did.
|
|
201
|
+
// Framing governs whether the agent follows the plugin: describe injected SKILL.md as complete
|
|
202
|
+
// instructions and let it require its references. Calling it a summary or making required reads
|
|
203
|
+
// conditional lets the outer wrapper override the plugin. The inventory-framing test pins this.
|
|
100
204
|
async function resolveOnePluginRef(pluginRefId, packagesDir, deliveryMode, bundledDigests) {
|
|
101
205
|
const installed = preferBundledAgentPluginDigests(await listInstalledPlugins(packagesDir), bundledDigests);
|
|
102
206
|
const matches = installed.filter((plugin) => plugin.pluginId === pluginRefId);
|
|
@@ -120,9 +224,10 @@ function buildModule(ports) {
|
|
|
120
224
|
let skillMarkdown;
|
|
121
225
|
try {
|
|
122
226
|
skillMarkdown = await readInstalledSkillMarkdown(plugin.packageRoot, skillPath);
|
|
227
|
+
// Notes are user context, never an allowlist or permission grant. JSON quotes delimit untrusted text.
|
|
123
228
|
const notes = await persistentState.memory({ workspaceRoot: packagesDir, pluginId: plugin.pluginId }).list({ kind: 'notes' });
|
|
124
229
|
if (notes.length)
|
|
125
|
-
skillMarkdown += `\n\nUser notes (context only
|
|
230
|
+
skillMarkdown += `\n\nUser notes for this plugin (context only; permissions remain operator-controlled):\n${JSON.stringify(notes)}`;
|
|
126
231
|
}
|
|
127
232
|
catch (error) {
|
|
128
233
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -142,6 +247,11 @@ function buildModule(ports) {
|
|
|
142
247
|
section: `<<AGENT_PLUGIN pluginId="${pluginRefId}">>\n${skillMarkdown}${inventory}\n<</AGENT_PLUGIN>>`,
|
|
143
248
|
};
|
|
144
249
|
}
|
|
250
|
+
// Parity with `inject`'s own failure: a pinned ref whose eponymous skill is missing must fail
|
|
251
|
+
// loudly in BOTH modes. Without this check `pointer` would happily emit a well-formed instruction
|
|
252
|
+
// whose no-argument default resolves to some OTHER (alphabetically-first) skill instead of the one
|
|
253
|
+
// the pin actually named, and the run would look like the agent disobeyed rather than like the pin
|
|
254
|
+
// being wrong.
|
|
145
255
|
function buildPointerSection(pluginRefId, plugin) {
|
|
146
256
|
if (!plugin.skills.some((skill) => skill.name === pluginRefId)) {
|
|
147
257
|
return {
|
|
@@ -1,7 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `retireBundledAgentPlugins()` — removes bundled Agent Plugins the host no longer ships from a
|
|
3
|
+
* workspace, and moves their users onto the plugin that absorbed them. Runs on every boot from
|
|
4
|
+
* `seed-bundled.ts`, after seeding, and is a no-op once a workspace has been migrated.
|
|
5
|
+
*
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* Why a retirement step exists
|
|
8
|
+
* ---------------------------------------------------------------------------
|
|
9
|
+
* Deleting `content/agent-plugins/<id>/` stops the seeder installing a plugin, but it removes nothing
|
|
10
|
+
* a workspace already has: the installed package stays under `packages/sha256/`, its activation
|
|
11
|
+
* record stays in `activations.json`, and its digest stays in `bundled-digests.json`. An ENABLED
|
|
12
|
+
* leftover keeps its skill in every prompt, now duplicating (and slowly contradicting) the successor
|
|
13
|
+
* that carries the same content. And the operator cannot remove it: `uninstall.ts` refuses a
|
|
14
|
+
* bundled plugin because it would be re-seeded, which is exactly what stops being true here. Owner
|
|
15
|
+
* rule: the user never fixes anything by hand, so the boot does it.
|
|
16
|
+
*
|
|
17
|
+
* ---------------------------------------------------------------------------
|
|
18
|
+
* What happens per retired id (idempotent)
|
|
19
|
+
* ---------------------------------------------------------------------------
|
|
20
|
+
* 1. Nothing installed and no activation record: nothing to do (the second boot after a migration).
|
|
21
|
+
* A leftover ledger entry is still dropped.
|
|
22
|
+
* 2. The retired plugin was ENABLED (explicitly, or by the "absent means active" default while its
|
|
23
|
+
* package is installed): the successor is switched on — unless an operator explicitly turned the
|
|
24
|
+
* successor off, which is left exactly as it is and reported in the outcome. No silent override.
|
|
25
|
+
* 3. The retired plugin's package(s) and activation record are removed through `uninstall.ts`
|
|
26
|
+
* (`retiredBundled: true` skips only its bundled refusal). A record with no package left is
|
|
27
|
+
* deleted directly.
|
|
28
|
+
* 4. The retired id is dropped from `bundled-digests.json`.
|
|
29
|
+
*
|
|
30
|
+
* A malformed or unreadable activation record for the retired id stops that id's retirement with a
|
|
31
|
+
* `failed` outcome and changes nothing: whether the operator had it enabled cannot be established,
|
|
32
|
+
* so neither enabling the successor nor deleting the record is safe. The next boot retries.
|
|
33
|
+
*
|
|
34
|
+
* Out of scope on purpose: nothing a retired plugin could own survives it. Agent Plugins have no
|
|
35
|
+
* per-plugin settings store (a workspace's `data/<pluginId>` directory has no production writer),
|
|
36
|
+
* and custom credentials (a saved `fly.io` token, say) belong to the workspace, not to a plugin.
|
|
37
|
+
*
|
|
38
|
+
* Architectural role:
|
|
39
|
+
* Composition over `activation.ts`, `uninstall.ts`, and `bundled-digests.ts`. Never throws: every
|
|
40
|
+
* failure is captured per id, exactly like `seed-bundled.ts`'s own outcomes, so one stuck retirement
|
|
41
|
+
* cannot stop a workspace from booting.
|
|
42
|
+
*/
|
|
1
43
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
|
-
import {
|
|
44
|
+
import type { BundledAgentPluginEnableOutcome } from "./activation.js";
|
|
3
45
|
import type { AgentPluginLayout } from "./layout.js";
|
|
4
46
|
import type { AgentPluginLifecyclePorts } from "./ports.js";
|
|
47
|
+
/** What happened to the successor. `not-needed`: the retired plugin was off, so the successor was
|
|
48
|
+
* left as it was. The other three are {@link BundledAgentPluginEnableOutcome}. */
|
|
5
49
|
export type RetiredAgentPluginSuccessorOutcome = BundledAgentPluginEnableOutcome | "not-needed";
|
|
6
50
|
export type RetiredAgentPluginOutcome = {
|
|
7
51
|
readonly pluginId: string;
|
|
@@ -11,9 +55,12 @@ export type RetiredAgentPluginOutcome = {
|
|
|
11
55
|
readonly pluginId: string;
|
|
12
56
|
readonly successorId: string;
|
|
13
57
|
readonly status: "retired";
|
|
58
|
+
/** Every installed archive digest removed; empty when only a record or ledger entry was left. */
|
|
14
59
|
readonly removedDigests: readonly string[];
|
|
15
60
|
readonly activationRemoved: boolean;
|
|
16
61
|
readonly ledgerEntryRemoved: boolean;
|
|
62
|
+
/** `left-disabled-by-operator` is the one case a caller should surface: the retired plugin was
|
|
63
|
+
* on, its successor was explicitly turned off, and this step did not override that. */
|
|
17
64
|
readonly successor: RetiredAgentPluginSuccessorOutcome;
|
|
18
65
|
} | {
|
|
19
66
|
readonly pluginId: string;
|
|
@@ -22,10 +69,12 @@ export type RetiredAgentPluginOutcome = {
|
|
|
22
69
|
readonly reason: string;
|
|
23
70
|
};
|
|
24
71
|
export interface RetireBundledAgentPluginsRequired {
|
|
72
|
+
/** The INSTANCE-level layout, for the same reason `seed-bundled.ts` takes one. */
|
|
25
73
|
readonly layout: AgentPluginLayout;
|
|
26
74
|
readonly workspaceId: string;
|
|
27
75
|
}
|
|
28
76
|
export interface RetireBundledAgentPluginsOptional {
|
|
77
|
+
/** Defaults to {@link RETIRED_BUNDLED_AGENT_PLUGINS}; tests pass their own. */
|
|
29
78
|
readonly retired?: (ReadonlyMap<string, string>) | undefined;
|
|
30
79
|
readonly now?: ((required: Record<string, never>) => Date) | undefined;
|
|
31
80
|
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
|
-
import {} from "./activation.js";
|
|
3
1
|
import { createActivationModule } from "./activation.js";
|
|
4
2
|
import { createBundledDigestsModule } from "./bundled-digests.js";
|
|
5
3
|
import { createResolveAgentPluginRefsModule } from "./resolve-agent-plugin-refs.js";
|
|
@@ -9,6 +7,13 @@ function buildModule(ports) {
|
|
|
9
7
|
const { removeBundledAgentPluginDigest } = createBundledDigestsModule(ports);
|
|
10
8
|
const { listInstalledPlugins } = createResolveAgentPluginRefsModule(ports);
|
|
11
9
|
const { uninstallAgentPlugin } = createUninstallModule(ports);
|
|
10
|
+
/**
|
|
11
|
+
* Retires every id in `retired` from one workspace. See this file's header for the steps.
|
|
12
|
+
*
|
|
13
|
+
* @returns One outcome per retired id, in map order.
|
|
14
|
+
* @throws Nothing.
|
|
15
|
+
* @complexity O(r) retired ids, each one `listInstalledPlugins` walk plus a few small file writes.
|
|
16
|
+
*/
|
|
12
17
|
async function retireBundledAgentPlugins(required, optional = {}) {
|
|
13
18
|
const outcomes = [];
|
|
14
19
|
for (const [pluginId, successorId] of optional.retired ?? ports.retiredBundledPlugins) {
|
|
@@ -21,6 +26,8 @@ function buildModule(ports) {
|
|
|
21
26
|
}
|
|
22
27
|
return outcomes;
|
|
23
28
|
}
|
|
29
|
+
// The successor first: if a later step fails, the operator keeps the capability, and the next boot
|
|
30
|
+
// (which finds this step already done) retries only what is left.
|
|
24
31
|
async function retireOne(required, optional, pluginId, successorId) {
|
|
25
32
|
const workspaceLayout = required.layout.forWorkspace({ workspaceId: required.workspaceId });
|
|
26
33
|
const workspaceRoot = workspaceLayout.root;
|