@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
package/dist/lifecycle/search.js
CHANGED
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
|
+
/** These describe the conversation or target, not a plugin's topic. Prose-only hits remain
|
|
3
|
+
* available to explicit search without silently loading unrelated guidance. */
|
|
4
|
+
const INCIDENTAL_TERMS = new Set('a an and are as at be build can could create do for from get help i in is it make me my new of on or please the this to use want we with you your site sites page pages website websites plugin plugins tell which load just don t anything'.split(' '));
|
|
5
|
+
const DEPLOYMENT_TERMS = new Set('deploy deployment deploying deployed publish publishing published host hosting hosted'.split(' '));
|
|
6
|
+
function automaticTerms(query) {
|
|
7
|
+
return [...new Set((query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter(term => !INCIDENTAL_TERMS.has(term)))];
|
|
8
|
+
}
|
|
9
|
+
/** A dedicated deployment provider (only `deploy-*` and `credential-scheme:*` capabilities) matches only a
|
|
10
|
+
* query naming both a deployment action and one of its vendor terms; any other plugin passes. */
|
|
11
|
+
function passesDeploymentGate({ candidate, capabilities, terms }, _options = {}) {
|
|
12
|
+
const deployment = capabilities.filter(capability => capability.startsWith('deploy-'));
|
|
13
|
+
const dedicatedDeployment = deployment.length > 0 && capabilities.every(capability => capability.startsWith('deploy-') || capability.startsWith('credential-scheme:'));
|
|
14
|
+
if (!dedicatedDeployment)
|
|
15
|
+
return true;
|
|
16
|
+
const vendors = automaticTerms([candidate.pluginId, candidate.displayName ?? '', ...deployment.map(capability => capability.split(':')[1] ?? '')].join(' '))
|
|
17
|
+
.filter(term => !DEPLOYMENT_TERMS.has(term));
|
|
18
|
+
return terms.some(term => DEPLOYMENT_TERMS.has(term)) && terms.some(term => vendors.includes(term));
|
|
19
|
+
}
|
|
20
|
+
/** Score complete authored terms only: long descriptions must not manufacture relevance.
|
|
21
|
+
* Dedicated deployment providers additionally need both the action and their declared vendor.
|
|
22
|
+
* Mixed-purpose plugins can still match their other capabilities (for example source control).
|
|
23
|
+
*/
|
|
24
|
+
function automaticScore({ candidate, terms }, _options = {}) {
|
|
25
|
+
if (!candidate.enabled)
|
|
26
|
+
return 0;
|
|
27
|
+
const capabilities = candidate.provides ?? [];
|
|
28
|
+
if (!passesDeploymentGate({ candidate, capabilities, terms }))
|
|
29
|
+
return 0;
|
|
30
|
+
const fields = [
|
|
31
|
+
{ terms: automaticTerms(capabilities.join(' ')), weight: 6 },
|
|
32
|
+
{ terms: automaticTerms(`${candidate.pluginId} ${candidate.id ?? ''} ${candidate.displayName ?? ''}`), weight: 5 },
|
|
33
|
+
{ terms: automaticTerms(candidate.keywords.join(' ')), weight: 4 },
|
|
34
|
+
];
|
|
35
|
+
return terms.reduce((score, term) => score + fields.reduce((sum, field) => sum + (field.terms.includes(term) ? field.weight : 0), 0), 0);
|
|
36
|
+
}
|
|
2
37
|
function buildModule(ports) {
|
|
3
38
|
const ID_FIELD_WEIGHT = 5;
|
|
4
39
|
const KEYWORD_FIELD_WEIGHT = 4;
|
|
@@ -18,7 +53,7 @@ function buildModule(ports) {
|
|
|
18
53
|
* plain, independently-readable list rather than interleaved with the scoring loop itself. */
|
|
19
54
|
function fieldHaystacks(candidate) {
|
|
20
55
|
return [
|
|
21
|
-
{ text: `${candidate.pluginId} ${candidate.id ?? ""}`.toLowerCase(), weight: ID_FIELD_WEIGHT },
|
|
56
|
+
{ text: `${candidate.pluginId} ${candidate.id ?? ""} ${candidate.displayName ?? ""}`.toLowerCase(), weight: ID_FIELD_WEIGHT },
|
|
22
57
|
{ text: [...candidate.keywords, ...(candidate.provides ?? [])].join(" ").toLowerCase(), weight: KEYWORD_FIELD_WEIGHT },
|
|
23
58
|
{ text: (candidate.description ?? "").toLowerCase(), weight: DESCRIPTION_FIELD_WEIGHT },
|
|
24
59
|
{
|
|
@@ -48,20 +83,23 @@ function buildModule(ports) {
|
|
|
48
83
|
* @param limit - Max matches to return, already clamped by the caller (mirrors
|
|
49
84
|
* `component-catalog-tool.ts`'s handler, which owns the clamp so this function can stay a pure
|
|
50
85
|
* "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
|
|
86
|
+
* @returns Matches with score > 0, sorted by score descending then plugin id, truncated to `limit`. A candidate
|
|
52
87
|
* that scores 0 (no term matched anything) is dropped, not returned at the bottom — the caller's own
|
|
53
88
|
* "N installed, 0 matched" framing is what tells the model the workspace is not empty even when a
|
|
54
89
|
* query matches nothing.
|
|
55
90
|
* @complexity See this file's header.
|
|
56
91
|
*/
|
|
57
|
-
function rankInstalledAgentPlugins(query, candidates, limit) {
|
|
58
|
-
const terms = tokenize(query);
|
|
92
|
+
function rankInstalledAgentPlugins(query, candidates, limit, automatic = false) {
|
|
93
|
+
const terms = automatic ? automaticTerms(query) : tokenize(query);
|
|
59
94
|
if (terms.length === 0)
|
|
60
95
|
return [];
|
|
61
|
-
|
|
62
|
-
.map((candidate) => ({ ...candidate, score: scoreCandidate(candidate, terms) }))
|
|
96
|
+
const ranked = candidates
|
|
97
|
+
.map((candidate) => ({ ...candidate, score: automatic ? automaticScore({ candidate, terms }) : scoreCandidate(candidate, terms) }))
|
|
63
98
|
.filter((match) => match.score > 0)
|
|
64
|
-
.sort((a, b) => b.score - a.score)
|
|
99
|
+
.sort((a, b) => b.score - a.score || a.pluginId.localeCompare(b.pluginId));
|
|
100
|
+
// A weak shared keyword must not ride alongside a substantially stronger task owner.
|
|
101
|
+
const threshold = automatic ? Math.max(4, (ranked[0]?.score ?? 0) * 0.6) : 0;
|
|
102
|
+
return ranked.filter(match => match.score >= threshold)
|
|
65
103
|
.slice(0, Math.max(limit, 0));
|
|
66
104
|
}
|
|
67
105
|
return { rankInstalledAgentPlugins };
|
|
@@ -20,9 +20,10 @@ export interface SetAgentPluginEnabledResult {
|
|
|
20
20
|
readonly pluginId: string;
|
|
21
21
|
/** As actually written and read back, not as requested. */
|
|
22
22
|
readonly enabled: boolean;
|
|
23
|
+
readonly warnings?: readonly string[];
|
|
23
24
|
}
|
|
24
25
|
/** Internal composition; one state/queue instance per injected host context. */
|
|
25
26
|
export declare function createSetEnabledModule(ports: AgentPluginLifecyclePorts): {
|
|
26
|
-
setAgentPluginEnabled: (input: SetAgentPluginEnabledInput) => Promise<SetAgentPluginEnabledResult>;
|
|
27
|
+
setAgentPluginEnabled: (input: SetAgentPluginEnabledInput, _optional?: Record<string, never>) => Promise<SetAgentPluginEnabledResult>;
|
|
27
28
|
};
|
|
28
29
|
//# sourceMappingURL=set-enabled.d.ts.map
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Extracted from the host plugin lifecycle. Effects are supplied per host context. */
|
|
2
|
+
import { createPersistentStateModule } from './persistent-state.js';
|
|
3
|
+
import { AgentPluginHostIncompatibleError, describeAgentPluginCompatibility, agentPluginDependentWarnings } from './compatibility.js';
|
|
2
4
|
import { createActivationModule } from "./activation.js";
|
|
3
5
|
import { createResolveAgentPluginRefsModule } from "./resolve-agent-plugin-refs.js";
|
|
6
|
+
import { createAgentPluginUpdateStore, settleAgentPluginUpdateJournal } from "./update-store.js";
|
|
4
7
|
/**
|
|
5
8
|
* The id named is not installed in this workspace. A distinct class rather than a bare `Error` so
|
|
6
9
|
* each caller can map it to its own boundary's vocabulary — 404 `AGENT_PLUGIN_NOT_FOUND` at the
|
|
@@ -13,21 +16,62 @@ export class AgentPluginNotInstalledError extends Error {
|
|
|
13
16
|
}
|
|
14
17
|
}
|
|
15
18
|
function buildModule(ports) {
|
|
16
|
-
const { setAgentPluginActivation } = createActivationModule(ports);
|
|
19
|
+
const { setAgentPluginActivation, readAgentPluginActivations } = createActivationModule(ports);
|
|
17
20
|
const { listInstalledPlugins } = createResolveAgentPluginRefsModule(ports);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const installed = await listInstalledPlugins(
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const persistentState = createPersistentStateModule(ports);
|
|
22
|
+
async function findInstalled({ workspaceRoot, pluginId }) {
|
|
23
|
+
const installed = await listInstalledPlugins(workspaceRoot);
|
|
24
|
+
const plugin = installed.find(candidate => candidate.pluginId === pluginId);
|
|
25
|
+
if (!plugin) {
|
|
26
|
+
throw new AgentPluginNotInstalledError({ pluginId });
|
|
23
27
|
}
|
|
28
|
+
return { installed, plugin };
|
|
29
|
+
}
|
|
30
|
+
/** Refuses a host-incompatible enable; otherwise returns advisory warnings, never blocking. */
|
|
31
|
+
async function enablementWarnings({ input, workspaceRoot, installed, plugin }) {
|
|
32
|
+
const activations = await readAgentPluginActivations(workspaceRoot);
|
|
33
|
+
if (!input.enabled)
|
|
34
|
+
return agentPluginDependentWarnings({ plugin, installed, activations, nextProvides: [] });
|
|
35
|
+
const compatibility = describeAgentPluginCompatibility({ plugin, installed, activations });
|
|
36
|
+
// Several digests of one id can be installed (a bundled upgrade lands beside the old one), and
|
|
37
|
+
// the listing order is arbitrary, so every installed revision must be host-compatible.
|
|
38
|
+
const revisions = installed.filter(candidate => candidate.pluginId === plugin.pluginId);
|
|
39
|
+
if (revisions.some(revision => !describeAgentPluginCompatibility({ plugin: revision, installed, activations }).hostOk)) {
|
|
40
|
+
throw new AgentPluginHostIncompatibleError({ pluginId: input.pluginId });
|
|
41
|
+
}
|
|
42
|
+
return compatibility.missingRequires.map(id => `Needs ${id}`);
|
|
43
|
+
}
|
|
44
|
+
async function setEnabledLocked({ input, workspaceRoot }) {
|
|
45
|
+
// Re-read under the lock: an uninstall or update may have finished while this call waited.
|
|
46
|
+
const { installed, plugin } = await findInstalled({ workspaceRoot, pluginId: input.pluginId });
|
|
47
|
+
// Boot recovery of an unfinished update would overwrite this decision, so it refuses
|
|
48
|
+
// (PLUGIN_UPDATE_PENDING) until a boot recovers it.
|
|
49
|
+
await settleAgentPluginUpdateJournal({ store: createAgentPluginUpdateStore({ ports, workspaceId: input.workspaceId, pluginId: input.pluginId }) });
|
|
50
|
+
const warnings = await enablementWarnings({ input, workspaceRoot, installed, plugin });
|
|
24
51
|
const written = await setAgentPluginActivation({
|
|
25
|
-
workspaceRoot
|
|
52
|
+
workspaceRoot,
|
|
26
53
|
pluginId: input.pluginId,
|
|
27
54
|
enabled: input.enabled,
|
|
28
55
|
actor: input.actor,
|
|
29
56
|
});
|
|
30
|
-
|
|
57
|
+
// Always present: setAgentPluginActivation asserts the same id grammar its normalizer keeps and
|
|
58
|
+
// writes a boolean `enabled`, so the record it returns holds this entry (the former
|
|
59
|
+
// `?? input.enabled` fallback was unreachable).
|
|
60
|
+
return { pluginId: input.pluginId, enabled: written.plugins[input.pluginId].enabled, ...(warnings.length ? { warnings } : {}) };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Serialized with install/update/uninstall of the same plugin by the per-plugin state lock, so a
|
|
64
|
+
* toggle never judges compatibility against a package that is mid-swap. The lock is a
|
|
65
|
+
* non-reentrant file lock: no caller holds it around this function (checked 2026-10-21 in this
|
|
66
|
+
* package and in Tovu's admin route / `plugins_set_enabled` tool).
|
|
67
|
+
*/
|
|
68
|
+
async function setAgentPluginEnabled(input, _optional = {}) {
|
|
69
|
+
const workspaceRoot = ports.layout.forWorkspace({ workspaceId: input.workspaceId }).root;
|
|
70
|
+
// Checked once before locking so an unknown or ungrammatical id stays a not-installed refusal
|
|
71
|
+
// and never creates workspace or lock directories (the lock path re-asserts the id grammar).
|
|
72
|
+
await findInstalled({ workspaceRoot, pluginId: input.pluginId });
|
|
73
|
+
return persistentState.withPluginLock({ workspaceRoot, pluginId: input.pluginId,
|
|
74
|
+
run: () => setEnabledLocked({ input, workspaceRoot }) });
|
|
31
75
|
}
|
|
32
76
|
return { setAgentPluginEnabled };
|
|
33
77
|
}
|
|
@@ -6,6 +6,36 @@ const TEXT_EXTENSIONS = new Set([".md", ".mdx", ".txt", ".json", ".yaml", ".yml"
|
|
|
6
6
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
7
7
|
const MAX_WALK_ENTRIES = 4096;
|
|
8
8
|
const MAX_WALK_DEPTH = 32;
|
|
9
|
+
const TOO_LARGE = "File request refused: exceeds the 256 KiB text limit; no content returned.";
|
|
10
|
+
const UNREADABLE = "File request refused: file is missing or unreadable.";
|
|
11
|
+
/** Reads an opened regular file of at most the cap; a file that grows past it after `stat` is still refused. */
|
|
12
|
+
async function readOpened(handle) {
|
|
13
|
+
const stat = await handle.stat();
|
|
14
|
+
if (!stat.isFile())
|
|
15
|
+
return { refusal: "File request refused: only regular text files are readable." };
|
|
16
|
+
if (stat.size > MAX_FILE_BYTES)
|
|
17
|
+
return { refusal: TOO_LARGE };
|
|
18
|
+
const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
19
|
+
let length = 0;
|
|
20
|
+
while (length < buffer.length) {
|
|
21
|
+
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
|
|
22
|
+
if (!bytesRead)
|
|
23
|
+
break;
|
|
24
|
+
length += bytesRead;
|
|
25
|
+
}
|
|
26
|
+
return length > MAX_FILE_BYTES ? { refusal: TOO_LARGE } : decodeText(buffer.subarray(0, length));
|
|
27
|
+
}
|
|
28
|
+
/** Admit only control-free UTF-8 text (tab, LF and CR allowed). */
|
|
29
|
+
function decodeText(bytes) {
|
|
30
|
+
if (bytes.some(byte => (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) || byte === 127))
|
|
31
|
+
return { refusal: "File request refused: binary content cannot be read as text." };
|
|
32
|
+
try {
|
|
33
|
+
return { text: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes) };
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { refusal: "File request refused: binary or invalid UTF-8 content cannot be read as text." };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
9
39
|
/**
|
|
10
40
|
* Progressive disclosure for plugin AND standalone skills, with a skill-folder trust boundary.
|
|
11
41
|
* Uses the lifecycle containment owner, native bounded reads and injected async filesystem effects.
|
|
@@ -14,67 +44,48 @@ const MAX_WALK_DEPTH = 32;
|
|
|
14
44
|
*/
|
|
15
45
|
export function createSkillFileReader({ skillRoot, filesystem }, _options = {}) {
|
|
16
46
|
const { assertContainedOnDisk } = createPackagePathsModule({ filesystem });
|
|
17
|
-
/** Reject traversal even when normalization would leave it inside the folder.
|
|
47
|
+
/** Reject traversal even when normalization would leave it inside the folder.
|
|
48
|
+
* @returns The refusal message, or `undefined` for an admissible name. */
|
|
18
49
|
function validateFile(file) {
|
|
19
50
|
if (!file || file.includes("\0") || file.includes("\\") || path.posix.isAbsolute(file) || /^[a-zA-Z]:/.test(file) || file.split("/").some(segment => segment === "..")) {
|
|
20
|
-
|
|
51
|
+
return "File request refused: use a path relative to this skill folder, without traversal or absolute paths.";
|
|
21
52
|
}
|
|
22
53
|
if (!TEXT_EXTENSIONS.has(path.extname(file).toLowerCase())) {
|
|
23
|
-
|
|
54
|
+
return "File request refused: only supported text extensions are readable.";
|
|
24
55
|
}
|
|
56
|
+
return undefined;
|
|
25
57
|
}
|
|
26
|
-
/**
|
|
27
|
-
async function
|
|
28
|
-
validateFile(file);
|
|
29
|
-
let absolute;
|
|
58
|
+
/** The canonical contained target of `file`, or `undefined` when it is missing or escapes the folder. */
|
|
59
|
+
async function resolveTarget(file) {
|
|
30
60
|
try {
|
|
31
|
-
absolute = await filesystem.realpath(await assertContainedOnDisk(skillRoot, file));
|
|
61
|
+
const absolute = await filesystem.realpath(await assertContainedOnDisk(skillRoot, file));
|
|
32
62
|
// Recheck the canonical target through the same owner before opening it.
|
|
33
63
|
const relative = path.relative(await filesystem.realpath(skillRoot), absolute);
|
|
34
64
|
await assertContainedOnDisk(skillRoot, relative);
|
|
35
|
-
validateFile(relative.split(path.sep).join("/"));
|
|
65
|
+
return validateFile(relative.split(path.sep).join("/")) === undefined ? absolute : undefined;
|
|
36
66
|
}
|
|
37
67
|
catch {
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
let handle;
|
|
41
|
-
try {
|
|
42
|
-
handle = await filesystem.open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
43
|
-
const stat = await handle.stat();
|
|
44
|
-
if (!stat.isFile())
|
|
45
|
-
throw new Error("File request refused: only regular text files are readable.");
|
|
46
|
-
if (stat.size > MAX_FILE_BYTES)
|
|
47
|
-
throw new Error("File request refused: exceeds the 256 KiB text limit; no content returned.");
|
|
48
|
-
const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
49
|
-
let length = 0;
|
|
50
|
-
while (length < buffer.length) {
|
|
51
|
-
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
|
|
52
|
-
if (!bytesRead)
|
|
53
|
-
break;
|
|
54
|
-
length += bytesRead;
|
|
55
|
-
}
|
|
56
|
-
if (length > MAX_FILE_BYTES)
|
|
57
|
-
throw new Error("File request refused: exceeds the 256 KiB text limit; no content returned.");
|
|
58
|
-
const bytes = buffer.subarray(0, length);
|
|
59
|
-
if (bytes.some(byte => (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) || byte === 127))
|
|
60
|
-
throw new Error("File request refused: binary content cannot be read as text.");
|
|
61
|
-
try {
|
|
62
|
-
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
throw new Error("File request refused: binary or invalid UTF-8 content cannot be read as text.");
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
catch (error) {
|
|
69
|
-
// Never reflect native errors or caller paths: either can contain an absolute host path.
|
|
70
|
-
if (error instanceof Error && error.message.startsWith("File request refused:"))
|
|
71
|
-
throw error;
|
|
72
|
-
throw new Error("File request refused: file is missing or unreadable.");
|
|
73
|
-
}
|
|
74
|
-
finally {
|
|
75
|
-
await handle?.close().catch(() => { });
|
|
68
|
+
return undefined;
|
|
76
69
|
}
|
|
77
70
|
}
|
|
71
|
+
/** Validate, resolve, then read one file; every refusal is a value, never a thrown native error. */
|
|
72
|
+
async function readText(file) {
|
|
73
|
+
const invalid = validateFile(file);
|
|
74
|
+
if (invalid !== undefined)
|
|
75
|
+
return { refusal: invalid };
|
|
76
|
+
const absolute = await resolveTarget(file);
|
|
77
|
+
if (absolute === undefined)
|
|
78
|
+
return { refusal: "File request refused: file is missing, unreadable, or resolves outside this skill folder." };
|
|
79
|
+
return readContained(absolute);
|
|
80
|
+
}
|
|
81
|
+
/** Open the resolved target with no-follow and read at most cap + 1, including growing files. */
|
|
82
|
+
async function readContained(absolute) {
|
|
83
|
+
const handle = await filesystem.open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(() => undefined);
|
|
84
|
+
if (!handle)
|
|
85
|
+
return { refusal: UNREADABLE };
|
|
86
|
+
// Never reflect native errors or caller paths: either can contain an absolute host path.
|
|
87
|
+
return readOpened(handle).catch(() => ({ refusal: UNREADABLE })).finally(() => handle.close().catch(() => { }));
|
|
88
|
+
}
|
|
78
89
|
/** Skip directory symlinks (including cycles); contained file symlinks use the same read guard. */
|
|
79
90
|
async function listFiles() {
|
|
80
91
|
const files = [];
|
|
@@ -85,45 +96,48 @@ export function createSkillFileReader({ skillRoot, filesystem }, _options = {})
|
|
|
85
96
|
incomplete = true;
|
|
86
97
|
return;
|
|
87
98
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const directory = relative ? await assertContainedOnDisk(skillRoot, relative) : await filesystem.realpath(skillRoot);
|
|
91
|
-
entries = await filesystem.readdir(directory, { withFileTypes: true });
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
99
|
+
const entries = await readDirectory(relative);
|
|
100
|
+
if (!entries) {
|
|
94
101
|
incomplete = true;
|
|
95
102
|
return;
|
|
96
103
|
}
|
|
97
|
-
for (const entry of entries
|
|
104
|
+
for (const entry of entries) {
|
|
98
105
|
if (remaining-- <= 0) {
|
|
99
106
|
incomplete = true;
|
|
100
107
|
break;
|
|
101
108
|
}
|
|
102
|
-
|
|
103
|
-
if (entry.isDirectory())
|
|
104
|
-
await walk(file, depth + 1);
|
|
105
|
-
else if (file !== "SKILL.md" && (entry.isFile() || entry.isSymbolicLink())) {
|
|
106
|
-
try {
|
|
107
|
-
await readText(file);
|
|
108
|
-
files.push(file);
|
|
109
|
-
}
|
|
110
|
-
catch { /* Refused files must not be advertised as readable. */ }
|
|
111
|
-
}
|
|
109
|
+
await visit(entry, relative ? `${relative}/${entry.name}` : entry.name, depth);
|
|
112
110
|
}
|
|
113
111
|
}
|
|
112
|
+
async function visit(entry, file, depth) {
|
|
113
|
+
if (entry.isDirectory())
|
|
114
|
+
return walk(file, depth + 1);
|
|
115
|
+
if (file === "SKILL.md" || !(entry.isFile() || entry.isSymbolicLink()))
|
|
116
|
+
return;
|
|
117
|
+
// Refused files must not be advertised as readable.
|
|
118
|
+
if ("text" in await readText(file))
|
|
119
|
+
files.push(file);
|
|
120
|
+
}
|
|
114
121
|
await walk("", 0);
|
|
115
122
|
return { files: files.sort(), ...(incomplete ? { note: "Supporting-file inventory is incomplete: traversal limit reached or a directory is unreadable." } : {}) };
|
|
116
123
|
}
|
|
124
|
+
/** One directory's entries in code-unit name order, or `undefined` when it cannot be read. */
|
|
125
|
+
async function readDirectory(relative) {
|
|
126
|
+
try {
|
|
127
|
+
const directory = relative ? await assertContainedOnDisk(skillRoot, relative) : await filesystem.realpath(skillRoot);
|
|
128
|
+
return (await filesystem.readdir(directory, { withFileTypes: true })).sort((a, b) => Number(a.name > b.name) - Number(a.name < b.name));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
117
134
|
return {
|
|
118
135
|
async read(_input, options = {}) {
|
|
119
136
|
const listing = await listFiles();
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const refusal = error instanceof Error ? error.message : "File request refused.";
|
|
125
|
-
return { files: listing.files, text: "", note: [refusal, `Valid supporting files: ${listing.files.join(", ") || "(none)"}. Omit file to request SKILL.md.`, listing.note].filter(Boolean).join(" ") };
|
|
126
|
-
}
|
|
137
|
+
const outcome = await readText(options.file ?? "SKILL.md");
|
|
138
|
+
if ("text" in outcome)
|
|
139
|
+
return { ...listing, text: outcome.text };
|
|
140
|
+
return { files: listing.files, text: "", note: [outcome.refusal, `Valid supporting files: ${listing.files.join(", ") || "(none)"}. Omit file to request SKILL.md.`, listing.note].filter(Boolean).join(" ") };
|
|
127
141
|
},
|
|
128
142
|
};
|
|
129
143
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Host capabilities a plugin tool receives at invocation, typed as ports: the host supplies
|
|
3
|
+
* every implementation (token store, guarded HTTP, limiter, artifact store) and Jini never names a
|
|
4
|
+
* provider. A per-call session wraps them so that (1) every capability stops working the moment the
|
|
5
|
+
* plugin's tools are revoked, even for a module that kept a reference, and (2) an access token the
|
|
6
|
+
* call obtained can never leave through the tool's result or error text. Those two gates are
|
|
7
|
+
* `openCapabilityGate`, shared with the admin-surface session so both enforce them identically.
|
|
8
|
+
*
|
|
9
|
+
* Plugin state reaches a tool read-only: the plugin's admin surface writes its settings (for example
|
|
10
|
+
* which of its products are on), and its tools must honor them, but an agent tool never changes them.
|
|
11
|
+
*/
|
|
12
|
+
/** Options for guarded HTTP; mirrors `@jini-ai/platform`'s guarded fetch, buffered bodies only. */
|
|
13
|
+
export interface AgentPluginHttpOptions {
|
|
14
|
+
readonly method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
15
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
16
|
+
readonly body?: string;
|
|
17
|
+
readonly signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
/** Mirrors `@jini-ai/http-kit`'s `RateLimitResult`, so a host can pass its limiter straight through. */
|
|
20
|
+
export type AgentPluginLimitResult = {
|
|
21
|
+
readonly allowed: true;
|
|
22
|
+
} | {
|
|
23
|
+
readonly allowed: false;
|
|
24
|
+
readonly retryAfterSeconds: number;
|
|
25
|
+
};
|
|
26
|
+
/** Plain JSON a plugin keeps in its plugin- and workspace-scoped state. */
|
|
27
|
+
export type AgentPluginStateValue = null | boolean | number | string | readonly AgentPluginStateValue[] | {
|
|
28
|
+
readonly [key: string]: AgentPluginStateValue;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The read-only view of a plugin's own state: what its tools get, and what a host hands any other
|
|
32
|
+
* plugin execution context (form delivery, for example) that must honor the plugin's settings. A
|
|
33
|
+
* host handing it elsewhere passes an object with `get` only, never its whole store.
|
|
34
|
+
*/
|
|
35
|
+
export interface AgentPluginStateReader {
|
|
36
|
+
get(required: {
|
|
37
|
+
readonly key: string;
|
|
38
|
+
}, optional?: Record<string, never>): Promise<AgentPluginStateValue | null>;
|
|
39
|
+
}
|
|
40
|
+
export interface AgentPluginToolCapabilities {
|
|
41
|
+
/** Resolves a fresh access token for a connection the host has scoped to this plugin and workspace.
|
|
42
|
+
* Never a refresh token. The host refuses a connectionRef the calling plugin does not own. */
|
|
43
|
+
readonly connections: {
|
|
44
|
+
getAccessToken(required: {
|
|
45
|
+
readonly connectionRef: string;
|
|
46
|
+
}, optional?: Record<string, never>): Promise<string>;
|
|
47
|
+
};
|
|
48
|
+
/** Outbound HTTP through the host's guarded client (allow-list, redirect refusal, deadlines). */
|
|
49
|
+
readonly http: {
|
|
50
|
+
fetch(required: {
|
|
51
|
+
readonly url: string;
|
|
52
|
+
}, optional?: AgentPluginHttpOptions): Promise<Response>;
|
|
53
|
+
};
|
|
54
|
+
/** Plugin-declared budgets; the host keys and sizes them. */
|
|
55
|
+
readonly limiter: {
|
|
56
|
+
check(required: {
|
|
57
|
+
readonly key: string;
|
|
58
|
+
}, optional?: Record<string, never>): Promise<AgentPluginLimitResult>;
|
|
59
|
+
};
|
|
60
|
+
/** Stores a file the run can hand to the user; returns an opaque reference, never a path. Absent
|
|
61
|
+
* when the host has no artifact store, so a tool checks `capabilities.artifacts` before relying on it. */
|
|
62
|
+
readonly artifacts?: {
|
|
63
|
+
write(required: {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly mediaType: string;
|
|
66
|
+
readonly content: string | Uint8Array;
|
|
67
|
+
}, optional?: Record<string, never>): Promise<{
|
|
68
|
+
readonly artifactRef: string;
|
|
69
|
+
}>;
|
|
70
|
+
};
|
|
71
|
+
/** The SAME store the plugin's admin-surface state writes, keyed by the same plugin and workspace
|
|
72
|
+
* scope. Read-only here: the session exposes `get` alone whatever object the host passes. */
|
|
73
|
+
readonly state: AgentPluginStateReader;
|
|
74
|
+
}
|
|
75
|
+
/** Context the host uses to scope capabilities: which plugin, which workspace/principal, which call. */
|
|
76
|
+
export interface AgentPluginToolCapabilityScope {
|
|
77
|
+
readonly pluginId: string;
|
|
78
|
+
readonly workspaceId: string;
|
|
79
|
+
readonly principalId?: string;
|
|
80
|
+
readonly signal?: AbortSignal;
|
|
81
|
+
}
|
|
82
|
+
/** Host port: builds the capabilities for one call. Called once per invocation. */
|
|
83
|
+
export type AgentPluginToolCapabilitiesPort = (required: AgentPluginToolCapabilityScope, optional?: Record<string, never>) => AgentPluginToolCapabilities;
|
|
84
|
+
/** One limit for every reader and writer of plugin state; the admin-surface limits reuse it. */
|
|
85
|
+
export declare const PLUGIN_STATE_MAX_KEY_LENGTH = 128;
|
|
86
|
+
export declare const REVOKED_MESSAGE = "This plugin tool was disabled; its capabilities are revoked. Re-enable the plugin and retry.";
|
|
87
|
+
export declare const WITHHELD_OUTPUT_MESSAGE = "The plugin tool result was withheld because it contained a connection credential.";
|
|
88
|
+
export declare const WITHHELD_ERROR_MESSAGE = "The plugin tool failed and its error was withheld because it contained a connection credential.";
|
|
89
|
+
/**
|
|
90
|
+
* Access tokens one plugin obtained, across all of its calls: plugin code that keeps a token in module
|
|
91
|
+
* scope must not be able to return or store it in a later call unseen.
|
|
92
|
+
*/
|
|
93
|
+
export type IssuedTokenMemory = Set<string>;
|
|
94
|
+
/** Newest tokens kept; access tokens are short-lived, so older ones matter less than bounded memory. */
|
|
95
|
+
export declare const ISSUED_TOKEN_MEMORY_LIMIT = 64;
|
|
96
|
+
/** The host's one source of token memories, passed to its tool projection AND its admin-surface host. */
|
|
97
|
+
export interface IssuedTokenMemories {
|
|
98
|
+
forPlugin(required: {
|
|
99
|
+
readonly pluginId: string;
|
|
100
|
+
}, optional?: Record<string, never>): IssuedTokenMemory;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* One token memory per plugin, shared by every context that runs that plugin's code: its tools, its
|
|
104
|
+
* surface handlers and their state writes. Why per plugin and not per context or generation: those
|
|
105
|
+
* contexts can share module scope (a common lib module), and Node caches an imported module, so its
|
|
106
|
+
* scope outlives a re-sync; a memory per context or per generation would let a token obtained in one
|
|
107
|
+
* leave through the other. A host creates ONE and passes it to both seams.
|
|
108
|
+
* @complexity O(1) per lookup; space O(plugins * ISSUED_TOKEN_MEMORY_LIMIT).
|
|
109
|
+
*/
|
|
110
|
+
export declare function createIssuedTokenMemories(_required?: Record<string, never>, _optional?: Record<string, never>): IssuedTokenMemories;
|
|
111
|
+
/**
|
|
112
|
+
* The credential scans alone, for plugin answers that need no capabilities (a tool's
|
|
113
|
+
* affected-resources disclosure): `mentionsToken`, and `sanitize` (an error, or a credential-free
|
|
114
|
+
* replacement without `cause`, since the cause may hold the token).
|
|
115
|
+
* @param required.issued - The plugin's token memory (see `createIssuedTokenMemories`).
|
|
116
|
+
* @complexity O(1) to open; the scans are O(scanned size * remembered tokens).
|
|
117
|
+
*/
|
|
118
|
+
export declare function openCredentialScan(required: {
|
|
119
|
+
readonly issued: IssuedTokenMemory;
|
|
120
|
+
}, _optional?: Record<string, never>): {
|
|
121
|
+
mentionsToken: ({ value }: {
|
|
122
|
+
readonly value: unknown;
|
|
123
|
+
}) => boolean;
|
|
124
|
+
sanitize({ error }: {
|
|
125
|
+
readonly error: unknown;
|
|
126
|
+
}): unknown;
|
|
127
|
+
};
|
|
128
|
+
export type AgentPluginCredentialScan = ReturnType<typeof openCredentialScan>;
|
|
129
|
+
/**
|
|
130
|
+
* The revocation and credential gates every capability session shares: this file's tool session and
|
|
131
|
+
* the admin-surface session (`../../admin-surfaces/surface-capabilities.ts`), so both revoke and scan
|
|
132
|
+
* the same way.
|
|
133
|
+
* @param required.isLive - False once the contributing plugin's contributions are revoked.
|
|
134
|
+
* @param required.issued - The plugin's token memory (see `createIssuedTokenMemories`).
|
|
135
|
+
* @returns `guard` (wraps a capability so it refuses once revoked), `noteToken` (records a token the
|
|
136
|
+
* call obtained), `mentionsToken`, and the `release` (result) and `sanitize` (error) gates.
|
|
137
|
+
* @complexity O(1) to open; the scans are O(serialized size * remembered tokens).
|
|
138
|
+
*/
|
|
139
|
+
export declare function openCapabilityGate(required: {
|
|
140
|
+
readonly isLive: () => boolean;
|
|
141
|
+
readonly issued: IssuedTokenMemory;
|
|
142
|
+
}, _optional?: Record<string, never>): {
|
|
143
|
+
/** The result, unless it mentions a token this plugin obtained. */
|
|
144
|
+
release<T>({ value }: {
|
|
145
|
+
readonly value: T;
|
|
146
|
+
}): T;
|
|
147
|
+
mentionsToken: ({ value }: {
|
|
148
|
+
readonly value: unknown;
|
|
149
|
+
}) => boolean;
|
|
150
|
+
sanitize({ error }: {
|
|
151
|
+
readonly error: unknown;
|
|
152
|
+
}): unknown;
|
|
153
|
+
guard: <A extends unknown[], R>(call: (...args: A) => Promise<R>) => (...args: A) => Promise<R>;
|
|
154
|
+
noteToken({ token }: {
|
|
155
|
+
readonly token: string;
|
|
156
|
+
}): string;
|
|
157
|
+
};
|
|
158
|
+
export type AgentPluginCapabilityGate = ReturnType<typeof openCapabilityGate>;
|
|
159
|
+
/** The host capabilities every session wraps the same way: guarded HTTP, limiter and artifacts
|
|
160
|
+
* (artifacts only when the host gives a store: absent stays absent, never a wrapper that throws). @complexity O(1). */
|
|
161
|
+
export declare function guardSharedCapabilities(required: {
|
|
162
|
+
readonly gate: AgentPluginCapabilityGate;
|
|
163
|
+
readonly host: Pick<AgentPluginToolCapabilities, 'http' | 'limiter' | 'artifacts'>;
|
|
164
|
+
}, _optional?: Record<string, never>): Pick<AgentPluginToolCapabilities, 'http' | 'limiter' | 'artifacts'>;
|
|
165
|
+
/** The key shape every plugin state call shares, so a key never reaches the host as a path or a scope. */
|
|
166
|
+
export declare function checkedStateKey(required: {
|
|
167
|
+
readonly key: unknown;
|
|
168
|
+
}, _optional?: Record<string, never>): string;
|
|
169
|
+
/**
|
|
170
|
+
* The read-only state view a session hands out: `get` alone, the key checked and passed on by itself
|
|
171
|
+
* (so plugin code cannot add a scope the host might honor), revoked with the session's gate.
|
|
172
|
+
* @complexity O(1) plus the key check.
|
|
173
|
+
*/
|
|
174
|
+
export declare function guardStateReader(required: {
|
|
175
|
+
readonly gate: AgentPluginCapabilityGate;
|
|
176
|
+
readonly state: AgentPluginStateReader;
|
|
177
|
+
}, _optional?: Record<string, never>): AgentPluginStateReader;
|
|
178
|
+
/**
|
|
179
|
+
* Opens the capability session for one tool call.
|
|
180
|
+
* @param required.capabilities - The host's capabilities for this call.
|
|
181
|
+
* @param required.isLive - False once the contributing plugin's tools are revoked.
|
|
182
|
+
* @param required.issued - The plugin's token memory, shared by all its calls and its surface handlers.
|
|
183
|
+
* @returns Wrapped capabilities plus `release` (result gate) and `sanitize` (error gate).
|
|
184
|
+
* @complexity O(1) to open; gates are O(serialized size).
|
|
185
|
+
*/
|
|
186
|
+
export declare function openCapabilitySession(required: {
|
|
187
|
+
readonly capabilities: AgentPluginToolCapabilities;
|
|
188
|
+
readonly isLive: () => boolean;
|
|
189
|
+
readonly issued: IssuedTokenMemory;
|
|
190
|
+
}, _optional?: Record<string, never>): {
|
|
191
|
+
capabilities: AgentPluginToolCapabilities;
|
|
192
|
+
release: <T>({ value }: {
|
|
193
|
+
readonly value: T;
|
|
194
|
+
}) => T;
|
|
195
|
+
sanitize: ({ error }: {
|
|
196
|
+
readonly error: unknown;
|
|
197
|
+
}) => unknown;
|
|
198
|
+
};
|
|
199
|
+
//# sourceMappingURL=capabilities.d.ts.map
|