@jini-ai/agent-plugins 0.4.7 → 0.4.8
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 +21 -0
- package/README.md +46 -0
- package/dist/admin-surfaces/action-dispatcher.d.ts +37 -0
- package/dist/admin-surfaces/action-dispatcher.js +69 -0
- package/dist/admin-surfaces/action-schema.d.ts +56 -0
- package/dist/admin-surfaces/action-schema.js +167 -0
- package/dist/admin-surfaces/action-tools.d.ts +40 -0
- package/dist/admin-surfaces/action-tools.js +52 -0
- package/dist/admin-surfaces/callback-field-inbox.d.ts +35 -0
- package/dist/admin-surfaces/callback-field-inbox.js +53 -0
- package/dist/admin-surfaces/capability-declarations.d.ts +41 -0
- package/dist/admin-surfaces/capability-declarations.js +102 -0
- package/dist/admin-surfaces/contribution.d.ts +49 -0
- package/dist/admin-surfaces/contribution.js +30 -0
- package/dist/admin-surfaces/delivery-bindings.d.ts +76 -0
- package/dist/admin-surfaces/delivery-bindings.js +99 -0
- package/dist/admin-surfaces/descriptor.d.ts +69 -0
- package/dist/admin-surfaces/descriptor.js +196 -0
- package/dist/admin-surfaces/external-links.d.ts +45 -0
- package/dist/admin-surfaces/external-links.js +70 -0
- package/dist/admin-surfaces/host-validator.d.ts +62 -0
- package/dist/admin-surfaces/host-validator.js +87 -0
- package/dist/admin-surfaces/index.d.ts +23 -0
- package/dist/admin-surfaces/index.js +21 -0
- package/dist/admin-surfaces/protocol.d.ts +103 -0
- package/dist/admin-surfaces/protocol.js +25 -0
- package/dist/admin-surfaces/sandbox.d.ts +78 -0
- package/dist/admin-surfaces/sandbox.js +92 -0
- package/dist/admin-surfaces/settings-value.d.ts +28 -0
- package/dist/admin-surfaces/settings-value.js +45 -0
- package/dist/admin-surfaces/surface-capabilities.d.ts +167 -0
- package/dist/admin-surfaces/surface-capabilities.js +215 -0
- package/dist/admin-surfaces/surface-client.d.ts +116 -0
- package/dist/admin-surfaces/surface-client.js +219 -0
- package/dist/admin-surfaces/surface-handlers.d.ts +111 -0
- package/dist/admin-surfaces/surface-handlers.js +172 -0
- package/dist/admin-surfaces/theme.d.ts +59 -0
- package/dist/admin-surfaces/theme.js +57 -0
- package/dist/folder-zip-node.js +59 -49
- package/dist/folder-zip.js +9 -7
- package/dist/lifecycle/automatic-use.d.ts +79 -0
- package/dist/lifecycle/automatic-use.js +165 -0
- package/dist/lifecycle/compatibility-metadata.d.ts +19 -0
- package/dist/lifecycle/compatibility-metadata.js +45 -0
- package/dist/lifecycle/compatibility.d.ts +52 -0
- package/dist/lifecycle/compatibility.js +60 -0
- package/dist/lifecycle/fetch-archive.js +26 -24
- package/dist/lifecycle/index.d.ts +14 -2
- package/dist/lifecycle/index.js +8 -1
- package/dist/lifecycle/install.js +7 -0
- package/dist/lifecycle/installed-package-index.js +41 -29
- package/dist/lifecycle/installed-package-roots.js +28 -20
- package/dist/lifecycle/manifest.d.ts +13 -0
- package/dist/lifecycle/manifest.js +115 -101
- package/dist/lifecycle/package-paths.js +3 -3
- package/dist/lifecycle/ports.d.ts +1 -1
- package/dist/lifecycle/provider-contribution.d.ts +65 -0
- package/dist/lifecycle/provider-contribution.js +93 -0
- package/dist/lifecycle/resolve-agent-plugin-refs.js +15 -2
- package/dist/lifecycle/retire-bundled.js +20 -18
- package/dist/lifecycle/search.d.ts +6 -1
- package/dist/lifecycle/search.js +45 -7
- package/dist/lifecycle/set-enabled.d.ts +2 -1
- package/dist/lifecycle/set-enabled.js +52 -8
- package/dist/lifecycle/skill-file-reader.js +85 -71
- package/dist/lifecycle/tools/capabilities.d.ts +199 -0
- package/dist/lifecycle/tools/capabilities.js +213 -0
- package/dist/lifecycle/tools/definition.d.ts +32 -0
- package/dist/lifecycle/tools/definition.js +33 -0
- package/dist/lifecycle/tools/index.d.ts +10 -0
- package/dist/lifecycle/tools/index.js +10 -0
- package/dist/lifecycle/tools/manifest.d.ts +33 -0
- package/dist/lifecycle/tools/manifest.js +89 -0
- package/dist/lifecycle/tools/projection.d.ts +90 -0
- package/dist/lifecycle/tools/projection.js +283 -0
- package/dist/lifecycle/types.d.ts +2 -0
- package/dist/lifecycle/uninstall.d.ts +3 -1
- package/dist/lifecycle/uninstall.js +51 -27
- package/dist/lifecycle/update-plan.d.ts +18 -0
- package/dist/lifecycle/update-plan.js +22 -0
- package/dist/lifecycle/update-store.d.ts +71 -0
- package/dist/lifecycle/update-store.js +150 -0
- package/dist/lifecycle/update.d.ts +55 -0
- package/dist/lifecycle/update.js +321 -0
- package/package.json +38 -6
- package/ui-ux-design/plugin.json +7 -2
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Projects loaded Agent Plugin tools into the EXISTING plugin tool contribution contract
|
|
3
|
+
* (`@jini-ai/plugins/host`: `PluginContributionRegistry` → `attachPluginContributionTools`), so these
|
|
4
|
+
* tools reach the assistant through the same namespaced ids, enablement recheck, auth port, confirmation
|
|
5
|
+
* and revocable registry scope as every other plugin tool. Nothing here registers tools directly.
|
|
6
|
+
*
|
|
7
|
+
* Added at this seam, because only it knows a tool came from an Agent Plugin module:
|
|
8
|
+
* - schema validation of the input before the module runs (the registry passes schemas through);
|
|
9
|
+
* - per-call host capabilities, revoked when the plugin's tools are withdrawn;
|
|
10
|
+
* - the affected-resources disclosure the host shows before confirming a write/destructive call;
|
|
11
|
+
* - refusal of ambiguous ids (two plugins whose namespaced ids collide) and of write/destructive
|
|
12
|
+
* tools that cannot disclose what they change.
|
|
13
|
+
*/
|
|
14
|
+
import { Validator } from '@cfworker/json-schema';
|
|
15
|
+
import { requireInputRecord, ToolInputError } from '@jini-ai/core';
|
|
16
|
+
import { openCapabilitySession, openCredentialScan, REVOKED_MESSAGE } from './capabilities.js';
|
|
17
|
+
const MAX_RESOURCES = 50;
|
|
18
|
+
const MAX_RESOURCE_LENGTH = 300;
|
|
19
|
+
/** Agent Plugin tools have no site-plugin SDK; the contract's slot is filled with one that refuses. */
|
|
20
|
+
const refuse = () => { throw new Error('Agent Plugin tools have no site-plugin SDK.'); };
|
|
21
|
+
const INERT_SDK = Object.freeze({
|
|
22
|
+
content: Object.freeze({ read: refuse, extend: refuse }), addFilter: refuse, addAction: refuse, addContribution: refuse,
|
|
23
|
+
routes: Object.freeze({ add: refuse }), tools: Object.freeze({ add: refuse }), widgets: Object.freeze({ add: refuse }),
|
|
24
|
+
});
|
|
25
|
+
/** Compiles the declared schema, or says why it cannot be used. @complexity O(s) schema size. */
|
|
26
|
+
function compileSchema(schema) {
|
|
27
|
+
try {
|
|
28
|
+
const validator = new Validator(schema, '2020-12', true);
|
|
29
|
+
// cfworker resolves references lazily; one probe surfaces a root-level unresolved $ref at load.
|
|
30
|
+
validator.validate({});
|
|
31
|
+
return validator;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return `its input schema is invalid (${error instanceof Error ? error.message : String(error)})`;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Why a loaded tool cannot be projected, or its admitted form. @complexity O(s) schema size. */
|
|
38
|
+
function admitOne(loaded, toolIdFor) {
|
|
39
|
+
const { descriptor, module } = loaded;
|
|
40
|
+
if (descriptor.effect !== 'read' && typeof module.describeAffectedResources !== 'function') {
|
|
41
|
+
return `a ${descriptor.effect} tool must export describeAffectedResources()`;
|
|
42
|
+
}
|
|
43
|
+
const validator = compileSchema(descriptor.inputSchema);
|
|
44
|
+
if (typeof validator === 'string')
|
|
45
|
+
return validator;
|
|
46
|
+
return { toolId: toolIdFor({ pluginId: loaded.pluginId, name: descriptor.name }), loaded, validator };
|
|
47
|
+
}
|
|
48
|
+
/** Admits each tool, then drops every id more than one plugin claims ("refusing to guess").
|
|
49
|
+
* @complexity O(t) tools plus schema compilation. */
|
|
50
|
+
function admitAll(loaded, refusals, toolIdFor) {
|
|
51
|
+
const admitted = [];
|
|
52
|
+
for (const entry of loaded) {
|
|
53
|
+
const result = admitOne(entry, toolIdFor);
|
|
54
|
+
if (typeof result === 'string')
|
|
55
|
+
refusals.push(`agent tool '${entry.descriptor.name}' from '${entry.pluginId}' was not loaded: ${result}`);
|
|
56
|
+
else
|
|
57
|
+
admitted.push(result);
|
|
58
|
+
}
|
|
59
|
+
const claimants = new Map();
|
|
60
|
+
for (const tool of admitted)
|
|
61
|
+
claimants.set(tool.toolId, (claimants.get(tool.toolId) ?? new Set()).add(tool.loaded.pluginId));
|
|
62
|
+
for (const [toolId, owners] of claimants) {
|
|
63
|
+
if (owners.size > 1)
|
|
64
|
+
refusals.push(`agent tool '${toolId}' was not loaded: more than one plugin declares it (${[...owners].map(id => `'${id}'`).sort().join(', ')})`);
|
|
65
|
+
}
|
|
66
|
+
return admitted.filter(tool => claimants.get(tool.toolId).size === 1);
|
|
67
|
+
}
|
|
68
|
+
/** Validates input against the declared schema; the message names locations, never values.
|
|
69
|
+
* @complexity O(n) input size. */
|
|
70
|
+
function validatedInput(tool, input) {
|
|
71
|
+
const record = requireInputRecord({ input });
|
|
72
|
+
let result;
|
|
73
|
+
// A schema fault reached only by some inputs (nested $ref, bad pattern) fails closed, not as bad input.
|
|
74
|
+
try {
|
|
75
|
+
result = tool.validator.validate(record);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new Error(`Plugin tool '${tool.toolId}' has an unusable input schema; report it to the plugin author.`, { cause: error });
|
|
79
|
+
}
|
|
80
|
+
if (result.valid)
|
|
81
|
+
return record;
|
|
82
|
+
// cfworker roots every instanceLocation at '#' (validate()'s default, children append '/key'), so it
|
|
83
|
+
// is never empty; pinned by "input failing at the schema root is located at #" in projection.unit.test.ts.
|
|
84
|
+
const where = [...new Set(result.errors.map(unit => `${unit.instanceLocation} (${unit.keyword})`))].slice(0, 3).join(', ');
|
|
85
|
+
throw new ToolInputError({ message: `Input for '${tool.toolId}' does not match its schema at ${where}. Fix the input and retry.` });
|
|
86
|
+
}
|
|
87
|
+
/** True when both generations hold the same modules and declarations: no revocation needed. @complexity O(t). */
|
|
88
|
+
function sameTools(previous, next, additional) {
|
|
89
|
+
if (!previous || previous.tools.length !== next.length || previous.additional.length !== additional.length
|
|
90
|
+
|| !previous.additional.every((tool, index) => tool === additional[index]))
|
|
91
|
+
return false;
|
|
92
|
+
return previous.tools.every((tool, index) => tool.loaded.module === next[index].loaded.module
|
|
93
|
+
&& JSON.stringify(tool.loaded.descriptor) === JSON.stringify(next[index].loaded.descriptor));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Checks a disclosure hook's answer. Its text reaches the confirmation prompt and the transcript,
|
|
97
|
+
* and module scope may still hold a token from an earlier call, so a list that mentions one is
|
|
98
|
+
* refused like a malformed one. @complexity O(r) resources plus the token scan.
|
|
99
|
+
*/
|
|
100
|
+
function checkedResources(toolId, value, scan) {
|
|
101
|
+
const valid = Array.isArray(value) && value.length <= MAX_RESOURCES
|
|
102
|
+
&& value.every(item => typeof item === 'string' && item.length > 0 && item.length <= MAX_RESOURCE_LENGTH)
|
|
103
|
+
&& !scan.mentionsToken({ value });
|
|
104
|
+
if (!valid)
|
|
105
|
+
throw new Error(`Plugin tool '${toolId}' returned an invalid affected-resources list; the call cannot be confirmed. Report it to the plugin author.`);
|
|
106
|
+
return Object.freeze([...value]);
|
|
107
|
+
}
|
|
108
|
+
/** Groups admitted tools and additional contributions in declaration order.
|
|
109
|
+
* Mutates only the local groups and appends admission failures to refusals. @complexity O(t) plus schema compilation. */
|
|
110
|
+
function groupProjectionTools({ loaded, additionalTools, refusals, toolIdFor }, _options = {}) {
|
|
111
|
+
const grouped = new Map();
|
|
112
|
+
for (const tool of admitAll(loaded, refusals, toolIdFor)) {
|
|
113
|
+
const group = grouped.get(tool.loaded.pluginId);
|
|
114
|
+
if (group)
|
|
115
|
+
group.push(tool);
|
|
116
|
+
else
|
|
117
|
+
grouped.set(tool.loaded.pluginId, [tool]);
|
|
118
|
+
}
|
|
119
|
+
const additional = new Map();
|
|
120
|
+
for (const entry of additionalTools ?? []) {
|
|
121
|
+
const group = additional.get(entry.pluginId);
|
|
122
|
+
if (group)
|
|
123
|
+
group.push(entry);
|
|
124
|
+
else
|
|
125
|
+
additional.set(entry.pluginId, [entry]);
|
|
126
|
+
if (!grouped.has(entry.pluginId))
|
|
127
|
+
grouped.set(entry.pluginId, []);
|
|
128
|
+
}
|
|
129
|
+
return { grouped, additional };
|
|
130
|
+
}
|
|
131
|
+
/** Counts all declarations, including refused tools, and appends ambiguous-id refusals.
|
|
132
|
+
* @complexity O(t * (r + 1)) time and O(t) space for t declarations and r prior refusals. */
|
|
133
|
+
function countProjectionClaims({ loaded, additionalTools, refusals, idFor }, _options = {}) {
|
|
134
|
+
// All claimants lose an ambiguous id, including collisions between executable and surface tools.
|
|
135
|
+
const counts = new Map();
|
|
136
|
+
const claimedIds = [...loaded.map(tool => idFor({ pluginId: tool.pluginId, name: tool.descriptor.name })),
|
|
137
|
+
...(additionalTools ?? []).map(entry => idFor({ pluginId: entry.pluginId, name: entry.tool.name }))];
|
|
138
|
+
for (const id of claimedIds)
|
|
139
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
140
|
+
for (const [id, count] of counts) {
|
|
141
|
+
if (count <= 1)
|
|
142
|
+
continue;
|
|
143
|
+
// MOVE: Admission already reports executable collisions with their owners; preserve that diagnostic once.
|
|
144
|
+
if (refusals.some(reason => reason.startsWith(`agent tool '${id}' was not loaded:`)))
|
|
145
|
+
continue;
|
|
146
|
+
refusals.push(`agent tool '${id}' was not loaded: more than one contribution declares it`);
|
|
147
|
+
}
|
|
148
|
+
return counts;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Creates the projection for one dedicated contribution registry (keyed by plugin id, so it must not
|
|
152
|
+
* be shared with site plugins whose ids could match). Call `sync` after every load: on boot and on
|
|
153
|
+
* every activation change. A plugin missing from a load is detached: its tools disappear and its
|
|
154
|
+
* calls' capabilities are revoked.
|
|
155
|
+
* @param required.registry - Registry the host attached with `attachPluginContributionTools`.
|
|
156
|
+
* @param required.toolIdFor - The contract's namespacing rule (`pluginToolIdFor`), so ids match the registry's.
|
|
157
|
+
* @param required.capabilities - Host port building one call's capabilities.
|
|
158
|
+
* @param required.issuedTokens - Per-plugin token memory shared with the plugin's admin-surface handlers.
|
|
159
|
+
* @complexity sync is O(t) tools plus schema compilation; disclosure lookup is O(1).
|
|
160
|
+
*/
|
|
161
|
+
export function createAgentPluginToolProjection(required, _optional = {}) {
|
|
162
|
+
const { registry } = required;
|
|
163
|
+
let projected = new Map();
|
|
164
|
+
let additionalById = new Map();
|
|
165
|
+
let byToolId = new Map();
|
|
166
|
+
/** One contract tool whose handler validates, scopes capabilities and gates the result. */
|
|
167
|
+
function contractTool(tool, generationOf) {
|
|
168
|
+
const { isLive, issued } = generationOf;
|
|
169
|
+
const { descriptor, module } = tool.loaded;
|
|
170
|
+
const contribution = {
|
|
171
|
+
name: descriptor.name, description: descriptor.description, inputSchema: descriptor.inputSchema, effect: descriptor.effect,
|
|
172
|
+
async handler({ pluginId, auth, input, signal }) {
|
|
173
|
+
// Defense in depth: the registry rechecks enablement, but a retained handler must not run either.
|
|
174
|
+
if (!isLive())
|
|
175
|
+
throw new Error(REVOKED_MESSAGE);
|
|
176
|
+
const checked = validatedInput(tool, input);
|
|
177
|
+
const host = required.capabilities({ pluginId, workspaceId: auth.workspaceId,
|
|
178
|
+
...(auth.principalId === undefined ? {} : { principalId: auth.principalId }), ...(signal === undefined ? {} : { signal }) });
|
|
179
|
+
const session = openCapabilitySession({ capabilities: host, isLive, issued });
|
|
180
|
+
try {
|
|
181
|
+
const value = await module.invoke({ input: checked, capabilities: session.capabilities, ...(signal === undefined ? {} : { signal }) });
|
|
182
|
+
return session.release({ value });
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
throw session.sanitize({ error });
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
return Object.freeze(contribution);
|
|
190
|
+
}
|
|
191
|
+
/** A fresh generation for one plugin; its tools are live only while the registry holds it. */
|
|
192
|
+
function generation(pluginId, tools, additional) {
|
|
193
|
+
let snapshot;
|
|
194
|
+
const isLive = () => snapshot !== undefined && registry.list({}).includes(snapshot);
|
|
195
|
+
const issued = required.issuedTokens.forPlugin({ pluginId });
|
|
196
|
+
snapshot = Object.freeze({ pluginId, sdk: INERT_SDK, routes: Object.freeze([]), widgets: Object.freeze([]),
|
|
197
|
+
tools: Object.freeze([...tools.map(tool => contractTool(tool, { isLive, issued })), ...additional.map(({ tool }) => Object.freeze({
|
|
198
|
+
...tool,
|
|
199
|
+
handler(input) {
|
|
200
|
+
if (!isLive())
|
|
201
|
+
throw new Error(REVOKED_MESSAGE);
|
|
202
|
+
return tool.handler(input);
|
|
203
|
+
},
|
|
204
|
+
}))]) });
|
|
205
|
+
return { snapshot, tools, additional };
|
|
206
|
+
}
|
|
207
|
+
/** Replaces one plugin's generation unless it is unchanged; a refused replacement detaches it. */
|
|
208
|
+
function place(pluginId, tools, additional, refusals) {
|
|
209
|
+
const previous = projected.get(pluginId);
|
|
210
|
+
if (sameTools(previous, tools, additional))
|
|
211
|
+
return previous;
|
|
212
|
+
const next = generation(pluginId, tools, additional);
|
|
213
|
+
try {
|
|
214
|
+
registry.replace({ contributions: next.snapshot });
|
|
215
|
+
return next;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
registry.detach({ pluginId });
|
|
219
|
+
refusals.push(`agent tools from '${pluginId}' were not loaded: ${error instanceof Error ? error.message : String(error)}`);
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
/** Projects one load result. @returns The loader's refusals plus this seam's own, in order. */
|
|
225
|
+
sync(input, _options = {}) {
|
|
226
|
+
const refusals = [...input.refusals];
|
|
227
|
+
const { grouped, additional } = groupProjectionTools({
|
|
228
|
+
loaded: input.loaded, additionalTools: input.additionalTools, refusals, toolIdFor: required.toolIdFor,
|
|
229
|
+
});
|
|
230
|
+
const idFor = required.toolIdFor;
|
|
231
|
+
const counts = countProjectionClaims({ loaded: input.loaded, additionalTools: input.additionalTools, refusals, idFor });
|
|
232
|
+
const nextProjected = new Map();
|
|
233
|
+
for (const [pluginId, tools] of grouped) {
|
|
234
|
+
const unique = tools.filter(tool => counts.get(tool.toolId) === 1);
|
|
235
|
+
const extras = (additional.get(pluginId) ?? []).filter(entry => counts.get(idFor({ pluginId, name: entry.tool.name })) === 1);
|
|
236
|
+
const placed = place(pluginId, unique, extras, refusals);
|
|
237
|
+
if (placed)
|
|
238
|
+
nextProjected.set(pluginId, placed);
|
|
239
|
+
}
|
|
240
|
+
for (const pluginId of projected.keys())
|
|
241
|
+
if (!grouped.has(pluginId))
|
|
242
|
+
registry.detach({ pluginId });
|
|
243
|
+
projected = nextProjected;
|
|
244
|
+
additionalById = new Map([...nextProjected.values()].flatMap(plugin => plugin.additional.map(entry => [idFor({ pluginId: entry.pluginId, name: entry.tool.name }), { entry, snapshot: plugin.snapshot }])));
|
|
245
|
+
byToolId = new Map([...nextProjected.values()].flatMap(plugin => plugin.tools.map(tool => [tool.toolId, { tool, snapshot: plugin.snapshot }])));
|
|
246
|
+
return { refusals };
|
|
247
|
+
},
|
|
248
|
+
/** What a call would change, for the confirmation prompt; null for an unknown or withdrawn tool.
|
|
249
|
+
* @throws ToolInputError for input that fails the schema; Error for an invalid hook answer. */
|
|
250
|
+
async describeAffectedResources(input, _options = {}) {
|
|
251
|
+
const extra = additionalById.get(input.toolId);
|
|
252
|
+
if (extra && registry.list({}).includes(extra.snapshot)) {
|
|
253
|
+
const scan = openCredentialScan({ issued: required.issuedTokens.forPlugin({ pluginId: extra.entry.pluginId }) });
|
|
254
|
+
try {
|
|
255
|
+
const resources = await extra.entry.describeAffectedResources({ input: input.input });
|
|
256
|
+
return { effect: extra.entry.tool.effect ?? 'write', resources: checkedResources(input.toolId, resources, scan) };
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
throw scan.sanitize({ error });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const entry = byToolId.get(input.toolId);
|
|
263
|
+
if (!entry || !registry.list({}).includes(entry.snapshot))
|
|
264
|
+
return null;
|
|
265
|
+
const { tool } = entry;
|
|
266
|
+
const checked = validatedInput(tool, input.input);
|
|
267
|
+
const describe = tool.loaded.module.describeAffectedResources;
|
|
268
|
+
if (!describe)
|
|
269
|
+
return { effect: tool.loaded.descriptor.effect, resources: [] };
|
|
270
|
+
// The same per-plugin token memory the plugin's calls feed: its answers and errors are scanned alike.
|
|
271
|
+
const scan = openCredentialScan({ issued: required.issuedTokens.forPlugin({ pluginId: tool.loaded.pluginId }) });
|
|
272
|
+
let answer;
|
|
273
|
+
try {
|
|
274
|
+
answer = await describe({ input: checked });
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
throw scan.sanitize({ error });
|
|
278
|
+
}
|
|
279
|
+
return { effect: tool.loaded.descriptor.effect, resources: checkedResources(tool.toolId, answer, scan) };
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=projection.js.map
|
|
@@ -8,6 +8,8 @@ export interface InstalledAgentPluginSkill {
|
|
|
8
8
|
export interface InstalledAgentPlugin {
|
|
9
9
|
readonly id?: string | undefined;
|
|
10
10
|
readonly provides?: readonly string[] | undefined;
|
|
11
|
+
readonly requires?: readonly string[] | undefined;
|
|
12
|
+
readonly hostRange?: string | undefined;
|
|
11
13
|
readonly pluginId: string;
|
|
12
14
|
readonly version?: (string) | undefined;
|
|
13
15
|
/** the host extension namespace in `plugin.json`: `displayName` — the human-facing title when the id alone reads
|
|
@@ -64,7 +64,9 @@ export interface StagedTree {
|
|
|
64
64
|
export declare function createUninstallModule(ports: AgentPluginLifecyclePorts): {
|
|
65
65
|
previewAgentPluginUninstall: (required: UninstallAgentPluginRequired) => Promise<AgentPluginUninstallPreview>;
|
|
66
66
|
uninstallAgentPlugin: (required: UninstallAgentPluginRequired, optional?: UninstallAgentPluginOptional) => Promise<UninstallAgentPluginResult>;
|
|
67
|
-
stageForRemoval: (packagesDir: string, packageRoot: string
|
|
67
|
+
stageForRemoval: (packagesDir: string, packageRoot: string, optional?: {
|
|
68
|
+
quarantined?: string;
|
|
69
|
+
}) => Promise<StagedTree>;
|
|
68
70
|
restoreStagedTrees: (staged: readonly StagedTree[], cause: unknown) => Promise<unknown>;
|
|
69
71
|
removeFrozenPackageTree: (root: string) => Promise<void>;
|
|
70
72
|
};
|
|
@@ -112,6 +112,7 @@ import path from "node:path";
|
|
|
112
112
|
import { createPackagePathsModule } from "./package-paths.js";
|
|
113
113
|
import { createActivationModule } from "./activation.js";
|
|
114
114
|
import { createResolveAgentPluginRefsModule } from "./resolve-agent-plugin-refs.js";
|
|
115
|
+
import { createAgentPluginUpdateStore, settleAgentPluginUpdateJournal } from "./update-store.js";
|
|
115
116
|
/** The plugin id has no installed package on disk for this workspace. */
|
|
116
117
|
export class AgentPluginNotFoundError extends Error {
|
|
117
118
|
constructor({ message }, optional = {}) { super(message, optional); }
|
|
@@ -174,50 +175,71 @@ function buildModule(ports) {
|
|
|
174
175
|
return createPersistentStateModule(ports).withPluginLock({ workspaceRoot, pluginId: required.pluginId,
|
|
175
176
|
run: () => uninstallAgentPluginUnlocked(required, optional) });
|
|
176
177
|
}
|
|
177
|
-
// Reversible work first — see this file's header, "Why removal stages first". Nothing on this
|
|
178
|
-
// side of the try is destructive: every step up to and including the activation-record delete can
|
|
179
|
-
// be undone by renaming the staged trees back where they came from.
|
|
180
|
-
// Past this point the live state is already settled: the packages are no longer reachable under
|
|
181
|
-
// any installed-digest name and the activation record is gone. These removes are the only
|
|
182
|
-
// irreversible step, and nothing observable depends on them finishing.
|
|
183
|
-
// Remove only empty package ancestors: a concurrently-installed digest must never be deleted.
|
|
184
|
-
// No package may have appeared since this uninstall's preview. Preserve it on a race.
|
|
185
178
|
async function uninstallAgentPluginUnlocked(required, optional = {}) {
|
|
186
179
|
const { workspaceRoot, packagesDir, matches } = await resolveUninstallTargets(required, optional.retiredBundled === true);
|
|
187
180
|
assertUnchangedSincePreview(required.pluginId, optional.confirmedPreview, matches);
|
|
188
|
-
|
|
181
|
+
assertMemoryDeletionConfirmed(required, optional);
|
|
182
|
+
// An unfinished update journal would make boot recovery resurrect the old revision over this
|
|
183
|
+
// uninstall, so it refuses (PLUGIN_UPDATE_PENDING) until a boot recovers it. The store resolves
|
|
184
|
+
// paths from this call's own layout, like every other path here.
|
|
185
|
+
const updates = createAgentPluginUpdateStore({ ports: { ...ports, layout: required.layout }, workspaceId: required.workspaceId, pluginId: required.pluginId });
|
|
186
|
+
await settleAgentPluginUpdateJournal({ store: updates });
|
|
187
|
+
const staged = await stageAndDeleteActivation({ workspaceRoot, packagesDir, pluginId: required.pluginId, matches });
|
|
188
|
+
// Past this point the live state is already settled: the packages are no longer reachable under
|
|
189
|
+
// any installed-digest name and the activation record is gone. These removes are the only
|
|
190
|
+
// irreversible step, and nothing observable depends on them finishing.
|
|
191
|
+
for (const tree of staged) {
|
|
192
|
+
await removeFrozenPackageTree(tree.quarantined);
|
|
193
|
+
}
|
|
194
|
+
await removeEmptyPackageAncestors({ packagesDir });
|
|
195
|
+
if (optional.deleteMemory)
|
|
196
|
+
await deletePersistentMemory({ ...required, workspaceRoot });
|
|
197
|
+
return { pluginId: required.pluginId, removedDigests: matches.map((plugin) => plugin.archiveDigest) };
|
|
198
|
+
}
|
|
199
|
+
function assertMemoryDeletionConfirmed(required, optional) {
|
|
200
|
+
if (optional.deleteMemory && optional.confirmedPreview?.pluginId !== required.pluginId) {
|
|
189
201
|
throw new Error('Deleting plugin memory requires a confirmed preview for this plugin');
|
|
190
202
|
}
|
|
203
|
+
}
|
|
204
|
+
/** Reversible work first — see this file's header, "Why removal stages first". Nothing in here is
|
|
205
|
+
* destructive: every step up to and including the activation-record delete can be undone by
|
|
206
|
+
* renaming the staged trees back where they came from. */
|
|
207
|
+
async function stageAndDeleteActivation(required) {
|
|
191
208
|
const staged = [];
|
|
192
209
|
try {
|
|
193
|
-
for (const plugin of matches) {
|
|
194
|
-
staged.push(await stageForRemoval(packagesDir, plugin.packageRoot));
|
|
210
|
+
for (const plugin of required.matches) {
|
|
211
|
+
staged.push(await stageForRemoval(required.packagesDir, plugin.packageRoot));
|
|
195
212
|
}
|
|
196
|
-
await deleteAgentPluginActivation({ workspaceRoot, pluginId: required.pluginId });
|
|
213
|
+
await deleteAgentPluginActivation({ workspaceRoot: required.workspaceRoot, pluginId: required.pluginId });
|
|
197
214
|
}
|
|
198
215
|
catch (error) {
|
|
199
216
|
throw await restoreStagedTrees(staged, error);
|
|
200
217
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
218
|
+
return staged;
|
|
219
|
+
}
|
|
220
|
+
/** Remove only empty package ancestors: a concurrently-installed digest must never be deleted. */
|
|
221
|
+
async function removeEmptyPackageAncestors(required) {
|
|
222
|
+
for (const directory of [required.packagesDir, path.dirname(required.packagesDir)]) {
|
|
205
223
|
try {
|
|
206
224
|
await rmdir(directory);
|
|
207
225
|
}
|
|
208
226
|
catch (error) {
|
|
209
|
-
if (!
|
|
227
|
+
if (!isAncestorStillInUse(error))
|
|
210
228
|
throw error;
|
|
211
229
|
}
|
|
212
230
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
231
|
+
}
|
|
232
|
+
function isAncestorStillInUse(error) {
|
|
233
|
+
return typeof error === 'object' && error !== null && 'code' in error && ['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(String(error.code));
|
|
234
|
+
}
|
|
235
|
+
/** No package may have appeared since this uninstall's preview. Preserve it on a race. */
|
|
236
|
+
async function deletePersistentMemory(required) {
|
|
237
|
+
const { workspaceRoot, pluginId } = required;
|
|
238
|
+
const pluginRoot = required.layout.forWorkspace({ workspaceId: required.workspaceId }).pluginRootDir({ pluginId });
|
|
239
|
+
await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, pluginRoot));
|
|
240
|
+
if ((await listInstalledPlugins(workspaceRoot)).some(plugin => plugin.pluginId === pluginId))
|
|
241
|
+
throw new Error('Plugin was reinstalled during uninstall; persistent memory was kept');
|
|
242
|
+
await rm(pluginRoot, { recursive: true, force: true });
|
|
221
243
|
}
|
|
222
244
|
/**
|
|
223
245
|
* Refuses when the digests resolved now are not exactly the set a confirmed preview listed — an archive added
|
|
@@ -245,8 +267,10 @@ function buildModule(ports) {
|
|
|
245
267
|
*
|
|
246
268
|
* @complexity One `stat`, one or two `chmod`s, one `rename`.
|
|
247
269
|
*/
|
|
248
|
-
async function stageForRemoval(packagesDir, packageRoot) {
|
|
249
|
-
|
|
270
|
+
async function stageForRemoval(packagesDir, packageRoot, optional = {}) {
|
|
271
|
+
// Updates choose a journaled destination outside packages/sha256; uninstall keeps its sibling
|
|
272
|
+
// destination and same-filesystem guarantee documented above. Both retain the same move owner.
|
|
273
|
+
const quarantined = optional.quarantined ?? path.join(packagesDir, `${STAGED_DIRNAME_PREFIX}${randomUUID()}`);
|
|
250
274
|
const originalMode = (await stat(packageRoot)).mode & 0o777;
|
|
251
275
|
await chmod(packageRoot, originalMode | 0o700);
|
|
252
276
|
try {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { InstalledAgentPlugin } from './types.js';
|
|
2
|
+
type Revision = Pick<InstalledAgentPlugin, 'pluginId' | 'archiveDigest' | 'version'> & {
|
|
3
|
+
readonly enabled?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export interface AgentPluginUpdatePlan {
|
|
6
|
+
readonly action: 'none' | 'update' | 'downgrade-refused' | 'id-mismatch';
|
|
7
|
+
readonly enabled: boolean;
|
|
8
|
+
readonly warnings: readonly string[];
|
|
9
|
+
}
|
|
10
|
+
/** Manifest semver orders releases; digest identifies bytes. Unknown or equal versions require
|
|
11
|
+
* an explicit replacement; automatic rollback bypasses planning. @complexity O(v) version length.
|
|
12
|
+
*/
|
|
13
|
+
export declare function planAgentPluginUpdate({ installed, candidate }: {
|
|
14
|
+
readonly installed: Revision;
|
|
15
|
+
readonly candidate: Revision;
|
|
16
|
+
}, _optional?: Record<string, never>): AgentPluginUpdatePlan;
|
|
17
|
+
export {};
|
|
18
|
+
//# sourceMappingURL=update-plan.d.ts.map
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import * as semver from 'semver';
|
|
2
|
+
/** Manifest semver orders releases; digest identifies bytes. Unknown or equal versions require
|
|
3
|
+
* an explicit replacement; automatic rollback bypasses planning. @complexity O(v) version length.
|
|
4
|
+
*/
|
|
5
|
+
export function planAgentPluginUpdate({ installed, candidate }, _optional = {}) {
|
|
6
|
+
const enabled = installed.enabled === true;
|
|
7
|
+
if (installed.pluginId !== candidate.pluginId)
|
|
8
|
+
return { action: 'id-mismatch', enabled, warnings: [] };
|
|
9
|
+
if (installed.archiveDigest === candidate.archiveDigest)
|
|
10
|
+
return { action: 'none', enabled, warnings: [] };
|
|
11
|
+
// `semver.valid` returns null for an absent version as well as a malformed one.
|
|
12
|
+
const from = semver.valid(installed.version);
|
|
13
|
+
const to = semver.valid(candidate.version);
|
|
14
|
+
if (!from || !to) {
|
|
15
|
+
return { action: 'update', enabled, warnings: ['Unknown release version; explicit replacement required.'] };
|
|
16
|
+
}
|
|
17
|
+
const order = semver.compare(to, from);
|
|
18
|
+
if (order < 0)
|
|
19
|
+
return { action: 'downgrade-refused', enabled, warnings: [] };
|
|
20
|
+
return { action: 'update', enabled, warnings: order === 0 ? ['Same version with different bytes; explicit replacement required.'] : [] };
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=update-plan.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AgentPluginLifecyclePorts } from './ports.js';
|
|
2
|
+
/** One update transaction's durable intent and, once finished, its audit record (`result`).
|
|
3
|
+
* `settingsSnapshot` is the host's NON-SECRET settings summary; hosts must never put secret values
|
|
4
|
+
* in it, because it is retained in the update history after the transaction ends. */
|
|
5
|
+
export interface AgentPluginUpdateJournal {
|
|
6
|
+
readonly schemaVersion: 1;
|
|
7
|
+
readonly state: 'pending' | 'published' | 'committed';
|
|
8
|
+
readonly transactionId: string;
|
|
9
|
+
readonly pluginId: string;
|
|
10
|
+
readonly actor: string;
|
|
11
|
+
readonly enabled: boolean;
|
|
12
|
+
readonly fromDigest: string;
|
|
13
|
+
readonly toDigest: string;
|
|
14
|
+
readonly fromVersion?: string | undefined;
|
|
15
|
+
readonly toVersion?: string | undefined;
|
|
16
|
+
readonly settingsSnapshot: unknown;
|
|
17
|
+
readonly startedAt: string;
|
|
18
|
+
readonly result?: 'updated' | 'rolled-back';
|
|
19
|
+
}
|
|
20
|
+
export declare class AgentPluginUpdateError extends Error {
|
|
21
|
+
readonly code: string;
|
|
22
|
+
constructor(required: {
|
|
23
|
+
code: string;
|
|
24
|
+
message: string;
|
|
25
|
+
}, optional?: ErrorOptions);
|
|
26
|
+
}
|
|
27
|
+
/** Durable per-plugin journal, audit history and frozen rollback area, all under the plugin's own
|
|
28
|
+
* state root (never under `package/sha256`, so a rollback copy is never a live digest). Files use
|
|
29
|
+
* the existing platform atomic writer. Settings snapshots are private (0600) and never returned to
|
|
30
|
+
* tools or HTTP callers.
|
|
31
|
+
* @complexity O(s) serialization of the host's settings slice.
|
|
32
|
+
*/
|
|
33
|
+
export declare function createAgentPluginUpdateStore({ ports, workspaceId, pluginId }: {
|
|
34
|
+
ports: AgentPluginLifecyclePorts;
|
|
35
|
+
workspaceId: string;
|
|
36
|
+
pluginId: string;
|
|
37
|
+
}, _optional?: Record<string, never>): {
|
|
38
|
+
read: (_required: Record<string, never>, _optional?: {}) => Promise<AgentPluginUpdateJournal | undefined>;
|
|
39
|
+
write: ({ journal, history }: {
|
|
40
|
+
journal: AgentPluginUpdateJournal;
|
|
41
|
+
history?: boolean;
|
|
42
|
+
}, _optional?: {}) => Promise<void>;
|
|
43
|
+
clear: (_required: Record<string, never>, _optional?: {}) => Promise<void>;
|
|
44
|
+
finish: ({ journal }: {
|
|
45
|
+
journal: AgentPluginUpdateJournal;
|
|
46
|
+
}, _optional?: {}) => Promise<void>;
|
|
47
|
+
prepare: (_required: Record<string, never>, _optional?: {}) => Promise<void>;
|
|
48
|
+
listRollbackRoots: (_required: Record<string, never>, _optional?: {}) => Promise<string[]>;
|
|
49
|
+
realDirectoryExists: ({ root: target }: {
|
|
50
|
+
root: string;
|
|
51
|
+
}, _optional?: {}) => Promise<boolean>;
|
|
52
|
+
rollbackDir: string;
|
|
53
|
+
rollbackRoot: ({ digest }: {
|
|
54
|
+
digest: string;
|
|
55
|
+
}, _optional?: {}) => string;
|
|
56
|
+
liveRoot: ({ digest }: {
|
|
57
|
+
digest: string;
|
|
58
|
+
}, _optional?: {}) => string;
|
|
59
|
+
};
|
|
60
|
+
/** Every mutation of a plugin (update, enable/disable, uninstall) runs this under the plugin lock
|
|
61
|
+
* first. An unfinished journal refuses it: boot recovery would otherwise resurrect the old revision
|
|
62
|
+
* over an uninstall or overwrite an operator's newer decision. A committed journal whose audit filing
|
|
63
|
+
* was interrupted is filed now.
|
|
64
|
+
* @throws AgentPluginUpdateError PLUGIN_UPDATE_PENDING, UPDATE_JOURNAL_INVALID. */
|
|
65
|
+
export declare function settleAgentPluginUpdateJournal({ store }: {
|
|
66
|
+
store: Pick<ReturnType<typeof createAgentPluginUpdateStore>, 'read' | 'finish'>;
|
|
67
|
+
}, _optional?: {}): Promise<void>;
|
|
68
|
+
export declare function isMissing({ error }: {
|
|
69
|
+
error: unknown;
|
|
70
|
+
}, _optional?: {}): boolean;
|
|
71
|
+
//# sourceMappingURL=update-store.d.ts.map
|