@nylorun/agents 0.2.0-beta → 0.3.0-beta
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 +12 -0
- package/README.md +2 -2
- package/dist/client.d.ts +68 -2
- package/dist/client.js +66 -0
- package/dist/executor.d.ts +1 -1
- package/dist/executor.js +4 -9
- package/dist/index.d.ts +7 -1
- package/dist/index.js +3 -0
- package/dist/plugins/launch.d.ts +17 -0
- package/dist/plugins/launch.js +71 -0
- package/dist/plugins/load.d.ts +23 -0
- package/dist/plugins/load.js +491 -0
- package/dist/plugins/plugin.d.ts +8 -0
- package/dist/plugins/plugin.js +45 -0
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @nylorun/agents
|
|
2
2
|
|
|
3
|
+
## 0.3.0-beta
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3a88f51: Ship Agent-Plugins (`plugin()` / `loadPlugin`), Skills (`load_skill` / skill resources), Runtime MCP pool + vault credentials, and manifest v3 capability fields. Validate completed tool `output` against the tool output schema so ordinary tools with `outputSchema` no longer false-fail as `tool.invalid-output`.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Pin core to the tested release.
|
|
12
|
+
- Updated dependencies [3a88f51]
|
|
13
|
+
- @nylorun/core@0.2.0-beta
|
|
14
|
+
|
|
3
15
|
## 0.2.0-beta
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -51,9 +51,9 @@ await connection.close();
|
|
|
51
51
|
|
|
52
52
|
Use `session.observe({ cursor, signal })` for resumable canonical events, `session.inspect()` for waiting/uncertain state, and `approve`, `respond`, or `cancel` with an explicit stable idempotency key. Retry the same semantic command with the same key. `ownerUserId` must come from trusted server authentication. Application credentials are not browser credentials; browser applications need an authorized backend. Definition authoring is browser-bundleable.
|
|
53
53
|
|
|
54
|
-
The Runtime destination is explicit, or defaults from `NYLORUN_RUNTIME_URL`. Application and executor keys default from `NYLORUN_SERVER_KEY` and `NYLORUN_EXECUTOR_KEY`. Implementation version defaults from `NYLORUN_IMPLEMENTATION_VERSION`, then `dev`. A host must provision executor scope for the
|
|
54
|
+
The Runtime destination is explicit, or defaults from `NYLORUN_RUNTIME_URL`. Application and executor keys default from `NYLORUN_SERVER_KEY` and `NYLORUN_EXECUTOR_KEY`. Implementation version defaults from `NYLORUN_IMPLEMENTATION_VERSION`, then `dev`. A host must provision executor scope for the agent id. Agents do not hash the manifest, and an in-flight action stays claimable after the registered digest changes. Model selection and model credentials belong to the Runtime.
|
|
55
55
|
|
|
56
|
-
`connectAgents({ agents })` opens authenticated fetch SSE before discovering actions, rediscovers after reconnection, and claims
|
|
56
|
+
`connectAgents({ agents })` opens authenticated fetch SSE before discovering actions, rediscovers after reconnection, and claims pending actions for a connected agent id. It renews leases while executing, then retries HTTP result delivery with the same recorded outcome and idempotency key. Reconnect delay is bounded at 30 seconds. Notifications confer no execution authority. There is no periodic action-discovery polling. Close aborts the stream, HTTP requests and lease timers and signals running functions; JavaScript cannot forcibly terminate a function that ignores its signal. The host makes expired in-flight actions uncertain instead of automatically repeating external effects.
|
|
57
57
|
|
|
58
58
|
Tools receive state, info, identity, resume, signal, approval/response helpers and memoized `step`. Step outcomes survive a persisted wait result; they do not establish exactly-once external effects after an unacknowledged crash. `sleep` and `waitFor` currently return inspectable deferred outcomes; automatic timer/event wakeups remain runtime implementation work. Remote `onModelCall` convenience and progress-event transport are not supplied in this pass. Arbitrary middleware closures are rejected for durable definitions; use before/after hooks. Agent definitions have no `.run()`; explicit local execution is available through `@nylorun/harness/run`.
|
|
59
59
|
|
package/dist/client.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { AgentManifest, BuiltAgent, JsonValue } from "@nylorun/core/define";
|
|
2
|
-
import { type SessionCommand } from "@nylorun/core/contracts";
|
|
2
|
+
import { type CredentialInfo, type CredentialSelection, type SessionCommand, type VaultInfo } from "@nylorun/core/contracts";
|
|
3
3
|
import { Transport, type Destination } from "./http.js";
|
|
4
4
|
export interface AgentSource {
|
|
5
5
|
readonly id: string;
|
|
6
6
|
readonly manifest: AgentManifest;
|
|
7
|
-
readonly hash: string;
|
|
8
7
|
build?(): BuiltAgent;
|
|
8
|
+
getBinding?: BuiltAgent["getBinding"];
|
|
9
9
|
}
|
|
10
|
+
/** Closures stay on the live binding. The published manifest cannot name them. */
|
|
11
|
+
export declare function assertNoMiddlewareClosures(agent: AgentSource): void;
|
|
10
12
|
export interface SessionView {
|
|
11
13
|
id: string;
|
|
12
14
|
agentId: string;
|
|
@@ -41,7 +43,71 @@ export declare class AgentsClient {
|
|
|
41
43
|
ownerUserId: string;
|
|
42
44
|
info?: Record<string, unknown>;
|
|
43
45
|
requestId?: string;
|
|
46
|
+
vaultIds?: readonly string[];
|
|
47
|
+
credentialSelections?: readonly CredentialSelection[];
|
|
44
48
|
}): Promise<SessionClient>;
|
|
49
|
+
createVault(options: {
|
|
50
|
+
name: string;
|
|
51
|
+
ownerUserId: string;
|
|
52
|
+
metadata?: Record<string, string>;
|
|
53
|
+
idempotencyKey: string;
|
|
54
|
+
requestId?: string;
|
|
55
|
+
}): Promise<VaultInfo>;
|
|
56
|
+
listVaults(ownerUserId: string, signal?: AbortSignal): Promise<{
|
|
57
|
+
vaults: VaultInfo[];
|
|
58
|
+
}>;
|
|
59
|
+
getVault(vaultId: string, signal?: AbortSignal): Promise<VaultInfo>;
|
|
60
|
+
deleteVault(vaultId: string): Promise<{
|
|
61
|
+
id: string;
|
|
62
|
+
}>;
|
|
63
|
+
createCredential(vaultId: string, options: {
|
|
64
|
+
name: string;
|
|
65
|
+
idempotencyKey: string;
|
|
66
|
+
requestId?: string;
|
|
67
|
+
auth: {
|
|
68
|
+
type: "bearer";
|
|
69
|
+
url: string;
|
|
70
|
+
token: string;
|
|
71
|
+
} | {
|
|
72
|
+
type: "oauth";
|
|
73
|
+
url: string;
|
|
74
|
+
accessToken: string;
|
|
75
|
+
expiresAt?: string | null;
|
|
76
|
+
refresh?: {
|
|
77
|
+
tokenEndpoint: string;
|
|
78
|
+
clientId: string;
|
|
79
|
+
refreshToken: string;
|
|
80
|
+
tokenEndpointAuth: {
|
|
81
|
+
type: "none";
|
|
82
|
+
} | {
|
|
83
|
+
type: "client_secret_basic";
|
|
84
|
+
clientSecret: string;
|
|
85
|
+
} | {
|
|
86
|
+
type: "client_secret_post";
|
|
87
|
+
clientSecret: string;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
}): Promise<CredentialInfo>;
|
|
92
|
+
listCredentials(vaultId: string, signal?: AbortSignal): Promise<{
|
|
93
|
+
credentials: CredentialInfo[];
|
|
94
|
+
}>;
|
|
95
|
+
getCredential(vaultId: string, credentialId: string, signal?: AbortSignal): Promise<CredentialInfo>;
|
|
96
|
+
rotateCredential(vaultId: string, credentialId: string, options: {
|
|
97
|
+
idempotencyKey: string;
|
|
98
|
+
requestId?: string;
|
|
99
|
+
auth: {
|
|
100
|
+
type: "bearer";
|
|
101
|
+
token: string;
|
|
102
|
+
} | {
|
|
103
|
+
type: "oauth";
|
|
104
|
+
accessToken: string;
|
|
105
|
+
expiresAt?: string | null;
|
|
106
|
+
};
|
|
107
|
+
}): Promise<CredentialInfo>;
|
|
108
|
+
deleteCredential(vaultId: string, credentialId: string): Promise<{
|
|
109
|
+
id: string;
|
|
110
|
+
}>;
|
|
45
111
|
session(sessionId: string): SessionClient;
|
|
46
112
|
}
|
|
47
113
|
export type CommandOptions = {
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
import { LiveEventSchema, SessionItemsResponseSchema, } from "@nylorun/core/contracts";
|
|
2
2
|
import { Transport, id, segment } from "./http.js";
|
|
3
3
|
import { observeSSE } from "./sse.js";
|
|
4
|
+
const middlewareClosure = "Hosted definitions support declarative capabilities and before/after hooks, not middleware closures";
|
|
5
|
+
function pluginRootsOf(agent) {
|
|
6
|
+
const built = agent.build?.() ?? agent;
|
|
7
|
+
const declarations = built.getBinding?.().declarations ?? [];
|
|
8
|
+
const roots = {};
|
|
9
|
+
for (const item of declarations) {
|
|
10
|
+
if (item.pluginRoot)
|
|
11
|
+
roots[item.id] = item.pluginRoot;
|
|
12
|
+
}
|
|
13
|
+
return Object.keys(roots).length === 0 ? undefined : roots;
|
|
14
|
+
}
|
|
15
|
+
/** Closures stay on the live binding. The published manifest cannot name them. */
|
|
16
|
+
export function assertNoMiddlewareClosures(agent) {
|
|
17
|
+
const built = agent.build?.() ?? agent;
|
|
18
|
+
if (built.getBinding?.().declarations.some((item) => item.hasMiddleware))
|
|
19
|
+
throw new Error(middlewareClosure);
|
|
20
|
+
}
|
|
4
21
|
export class AgentsClient {
|
|
5
22
|
transport;
|
|
6
23
|
constructor(destination = {}) {
|
|
@@ -16,10 +33,13 @@ export class AgentsClient {
|
|
|
16
33
|
return this.transport.json(`/v1/sessions${query}`, "GET", undefined, options.signal);
|
|
17
34
|
}
|
|
18
35
|
saveAgent(agent, options) {
|
|
36
|
+
assertNoMiddlewareClosures(agent);
|
|
37
|
+
const pluginRoots = pluginRootsOf(agent);
|
|
19
38
|
return this.transport.json(`/v1/agents/${segment(agent.id)}`, "PUT", {
|
|
20
39
|
requestId: options.requestId ?? id(),
|
|
21
40
|
manifest: agent.manifest,
|
|
22
41
|
implementationVersion: options.implementationVersion,
|
|
42
|
+
...(pluginRoots === undefined ? {} : { pluginRoots }),
|
|
23
43
|
});
|
|
24
44
|
}
|
|
25
45
|
async createSession(options) {
|
|
@@ -29,9 +49,55 @@ export class AgentsClient {
|
|
|
29
49
|
agentId: options.agentId,
|
|
30
50
|
ownerUserId: options.ownerUserId,
|
|
31
51
|
...(options.info ? { info: options.info } : {}),
|
|
52
|
+
...(options.vaultIds ? { vaultIds: options.vaultIds } : {}),
|
|
53
|
+
...(options.credentialSelections
|
|
54
|
+
? { credentialSelections: options.credentialSelections }
|
|
55
|
+
: {}),
|
|
32
56
|
});
|
|
33
57
|
return this.session(sessionId);
|
|
34
58
|
}
|
|
59
|
+
createVault(options) {
|
|
60
|
+
return this.transport.json("/v1/vaults", "POST", {
|
|
61
|
+
requestId: options.requestId ?? id(),
|
|
62
|
+
idempotencyKey: options.idempotencyKey,
|
|
63
|
+
name: options.name,
|
|
64
|
+
ownerUserId: options.ownerUserId,
|
|
65
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
listVaults(ownerUserId, signal) {
|
|
69
|
+
return this.transport.json(`/v1/vaults?ownerUserId=${encodeURIComponent(ownerUserId)}`, "GET", undefined, signal);
|
|
70
|
+
}
|
|
71
|
+
getVault(vaultId, signal) {
|
|
72
|
+
return this.transport.json(`/v1/vaults/${segment(vaultId)}`, "GET", undefined, signal);
|
|
73
|
+
}
|
|
74
|
+
deleteVault(vaultId) {
|
|
75
|
+
return this.transport.json(`/v1/vaults/${segment(vaultId)}`, "DELETE");
|
|
76
|
+
}
|
|
77
|
+
createCredential(vaultId, options) {
|
|
78
|
+
return this.transport.json(`/v1/vaults/${segment(vaultId)}/credentials`, "POST", {
|
|
79
|
+
requestId: options.requestId ?? id(),
|
|
80
|
+
idempotencyKey: options.idempotencyKey,
|
|
81
|
+
name: options.name,
|
|
82
|
+
auth: options.auth,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
listCredentials(vaultId, signal) {
|
|
86
|
+
return this.transport.json(`/v1/vaults/${segment(vaultId)}/credentials`, "GET", undefined, signal);
|
|
87
|
+
}
|
|
88
|
+
getCredential(vaultId, credentialId, signal) {
|
|
89
|
+
return this.transport.json(`/v1/vaults/${segment(vaultId)}/credentials/${segment(credentialId)}`, "GET", undefined, signal);
|
|
90
|
+
}
|
|
91
|
+
rotateCredential(vaultId, credentialId, options) {
|
|
92
|
+
return this.transport.json(`/v1/vaults/${segment(vaultId)}/credentials/${segment(credentialId)}`, "POST", {
|
|
93
|
+
requestId: options.requestId ?? id(),
|
|
94
|
+
idempotencyKey: options.idempotencyKey,
|
|
95
|
+
auth: options.auth,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
deleteCredential(vaultId, credentialId) {
|
|
99
|
+
return this.transport.json(`/v1/vaults/${segment(vaultId)}/credentials/${segment(credentialId)}`, "DELETE");
|
|
100
|
+
}
|
|
35
101
|
session(sessionId) {
|
|
36
102
|
return new SessionClient(this.transport, sessionId);
|
|
37
103
|
}
|
package/dist/executor.d.ts
CHANGED
package/dist/executor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AgentManifestSchema, ActionSchema, ActionClaimResponseSchema, } from "@nylorun/core/contracts";
|
|
2
|
+
import { assertNoMiddlewareClosures } from "./client.js";
|
|
2
3
|
import { Transport, RuntimeError, delay, env, id, segment, } from "./http.js";
|
|
3
4
|
import { readSSE } from "./sse.js";
|
|
4
5
|
import { executeAction } from "./execute-action.js";
|
|
@@ -10,6 +11,7 @@ export function connectAgents(options) {
|
|
|
10
11
|
const agents = new Map();
|
|
11
12
|
for (const source of options.agents) {
|
|
12
13
|
const built = source.build?.() ?? source;
|
|
14
|
+
assertNoMiddlewareClosures(built);
|
|
13
15
|
AgentManifestSchema.parse(built.manifest);
|
|
14
16
|
if (agents.has(built.id))
|
|
15
17
|
throw new Error(`Duplicate connected agent ${built.id}`);
|
|
@@ -47,11 +49,7 @@ export function connectAgents(options) {
|
|
|
47
49
|
for (const item of response.actions) {
|
|
48
50
|
const action = ActionSchema.parse(item);
|
|
49
51
|
const agent = agents.get(action.agentId);
|
|
50
|
-
if (!agent ||
|
|
51
|
-
agent.hash !== action.manifestHash ||
|
|
52
|
-
action.implementationVersion !== version ||
|
|
53
|
-
action.status !== "pending" ||
|
|
54
|
-
active.has(action.actionId))
|
|
52
|
+
if (!agent || action.status !== "pending" || active.has(action.actionId))
|
|
55
53
|
continue;
|
|
56
54
|
const work = new AbortController();
|
|
57
55
|
active.set(action.actionId, work);
|
|
@@ -86,12 +84,9 @@ export function connectAgents(options) {
|
|
|
86
84
|
try {
|
|
87
85
|
const claim = ActionClaimResponseSchema.parse(await transport.json(`/v1/actions/${segment(action.actionId)}/claim`, "POST", {
|
|
88
86
|
requestId: id(),
|
|
89
|
-
manifestHash: agent.hash,
|
|
90
87
|
implementationVersion: version,
|
|
91
88
|
}, work.signal));
|
|
92
|
-
if (claim.action.actionId !== action.actionId
|
|
93
|
-
claim.action.manifestHash !== agent.hash ||
|
|
94
|
-
claim.action.implementationVersion !== version)
|
|
89
|
+
if (claim.action.actionId !== action.actionId)
|
|
95
90
|
throw new Error("Claim definition mismatch");
|
|
96
91
|
let expires = Date.parse(claim.leaseExpiresAt);
|
|
97
92
|
renewal = (async () => {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
export { Agent, AgentBuilder, AgentBuildError, AgentLifecycleError, capability, tool, defineSchema, ToolError, } from "@nylorun/core/define";
|
|
2
2
|
export type { AgentOptions, AgentManifest, BuiltAgent, ToolDefinition, ToolExecutionContext, ToolOutcome, Patch, Decision, JsonValue, JsonObject, } from "@nylorun/core/define";
|
|
3
|
-
export type { LiveEvent, SessionCommand, Action, ActionOutcome, } from "@nylorun/core/contracts";
|
|
3
|
+
export type { LiveEvent, SessionCommand, Action, ActionOutcome, CredentialInfo, CredentialSelection, VaultInfo, } from "@nylorun/core/contracts";
|
|
4
4
|
export { AgentsClient, SessionClient, createClient } from "./client.js";
|
|
5
5
|
export type { AgentSource, SessionView, CommandOptions } from "./client.js";
|
|
6
6
|
export { connectAgents } from "./executor.js";
|
|
7
7
|
export type { ConnectOptions, AgentConnection } from "./executor.js";
|
|
8
8
|
export { RuntimeError } from "./http.js";
|
|
9
9
|
export type { Destination } from "./http.js";
|
|
10
|
+
export { plugin } from "./plugins/plugin.js";
|
|
11
|
+
export type { PluginCapability } from "./plugins/plugin.js";
|
|
12
|
+
export { loadPlugin, PluginError } from "./plugins/load.js";
|
|
13
|
+
export type { LoadedPlugin, PluginDiagnostic } from "./plugins/load.js";
|
|
14
|
+
export { prepareStdioLaunch, expandPluginPlaceholders } from "./plugins/launch.js";
|
|
15
|
+
export type { StdioLaunch } from "./plugins/launch.js";
|
package/dist/index.js
CHANGED
|
@@ -3,3 +3,6 @@ export { Agent, AgentBuilder, AgentBuildError, AgentLifecycleError, capability,
|
|
|
3
3
|
export { AgentsClient, SessionClient, createClient } from "./client.js";
|
|
4
4
|
export { connectAgents } from "./executor.js";
|
|
5
5
|
export { RuntimeError } from "./http.js";
|
|
6
|
+
export { plugin } from "./plugins/plugin.js";
|
|
7
|
+
export { loadPlugin, PluginError } from "./plugins/load.js";
|
|
8
|
+
export { prepareStdioLaunch, expandPluginPlaceholders } from "./plugins/launch.js";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { McpServerManifest } from "@nylorun/core/define";
|
|
2
|
+
/** Single-pass replacement. Text inserted for a token is not scanned again. */
|
|
3
|
+
export declare function expandPluginPlaceholders(value: string, pluginRoot: string, pluginData: string): string;
|
|
4
|
+
export interface StdioLaunch {
|
|
5
|
+
readonly command: string;
|
|
6
|
+
readonly args: readonly string[];
|
|
7
|
+
readonly cwd: string;
|
|
8
|
+
readonly env: Readonly<Record<string, string>>;
|
|
9
|
+
}
|
|
10
|
+
export declare function prepareStdioLaunch(server: Extract<McpServerManifest, {
|
|
11
|
+
type: "stdio";
|
|
12
|
+
}>, options: {
|
|
13
|
+
readonly pluginRoot: string;
|
|
14
|
+
readonly pluginData: string;
|
|
15
|
+
readonly baseEnv?: Readonly<Record<string, string | undefined>>;
|
|
16
|
+
}): StdioLaunch;
|
|
17
|
+
export declare function resolveContainedCwd(cwd: string, pluginRoot: string, pluginData: string): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
const ROOT_TOKEN = "${PLUGIN_ROOT}";
|
|
4
|
+
const DATA_TOKEN = "${PLUGIN_DATA}";
|
|
5
|
+
/** Single-pass replacement. Text inserted for a token is not scanned again. */
|
|
6
|
+
export function expandPluginPlaceholders(value, pluginRoot, pluginData) {
|
|
7
|
+
let out = "";
|
|
8
|
+
for (let index = 0; index < value.length;) {
|
|
9
|
+
if (value.startsWith(ROOT_TOKEN, index)) {
|
|
10
|
+
out += pluginRoot;
|
|
11
|
+
index += ROOT_TOKEN.length;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (value.startsWith(DATA_TOKEN, index)) {
|
|
15
|
+
out += pluginData;
|
|
16
|
+
index += DATA_TOKEN.length;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
out += value[index];
|
|
20
|
+
index += 1;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export function prepareStdioLaunch(server, options) {
|
|
25
|
+
const pluginRoot = options.pluginRoot;
|
|
26
|
+
const pluginData = options.pluginData;
|
|
27
|
+
mkdirSync(pluginData, { recursive: true });
|
|
28
|
+
const command = resolveCommand(server.command, pluginRoot);
|
|
29
|
+
const args = (server.args ?? []).map((arg) => expandPluginPlaceholders(arg, pluginRoot, pluginData));
|
|
30
|
+
const cwd = server.cwd
|
|
31
|
+
? resolveContainedCwd(server.cwd, pluginRoot, pluginData)
|
|
32
|
+
: pluginRoot;
|
|
33
|
+
const env = {};
|
|
34
|
+
for (const [key, value] of Object.entries(options.baseEnv ?? {})) {
|
|
35
|
+
if (typeof value === "string")
|
|
36
|
+
setEnv(env, key, value);
|
|
37
|
+
}
|
|
38
|
+
for (const [key, value] of Object.entries(server.env ?? {}))
|
|
39
|
+
setEnv(env, key, expandPluginPlaceholders(value, pluginRoot, pluginData));
|
|
40
|
+
setEnv(env, "PLUGIN_ROOT", pluginRoot);
|
|
41
|
+
setEnv(env, "PLUGIN_DATA", pluginData);
|
|
42
|
+
return { command, args, cwd, env };
|
|
43
|
+
}
|
|
44
|
+
function resolveCommand(command, pluginRoot) {
|
|
45
|
+
if (command.startsWith("./"))
|
|
46
|
+
return resolve(pluginRoot, command);
|
|
47
|
+
return command;
|
|
48
|
+
}
|
|
49
|
+
export function resolveContainedCwd(cwd, pluginRoot, pluginData) {
|
|
50
|
+
const expanded = expandPluginPlaceholders(cwd, pluginRoot, pluginData);
|
|
51
|
+
const absolute = isAbsolute(expanded) ? expanded : resolve(pluginRoot, expanded);
|
|
52
|
+
const root = cwdTargetsData(cwd) ? pluginData : pluginRoot;
|
|
53
|
+
if (!isInside(root, absolute))
|
|
54
|
+
throw new Error(`Working directory escapes ${root}`);
|
|
55
|
+
return absolute;
|
|
56
|
+
}
|
|
57
|
+
function cwdTargetsData(cwd) {
|
|
58
|
+
return cwd === DATA_TOKEN || cwd.startsWith(`${DATA_TOKEN}/`);
|
|
59
|
+
}
|
|
60
|
+
function isInside(root, target) {
|
|
61
|
+
const rel = relative(root, target);
|
|
62
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
63
|
+
}
|
|
64
|
+
function setEnv(env, key, value) {
|
|
65
|
+
if (process.platform === "win32") {
|
|
66
|
+
const existing = Object.keys(env).find((name) => name.toLowerCase() === key.toLowerCase());
|
|
67
|
+
if (existing && existing !== key)
|
|
68
|
+
delete env[existing];
|
|
69
|
+
}
|
|
70
|
+
env[key] = value;
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { McpServerManifest, SkillRecord } from "@nylorun/core/define";
|
|
2
|
+
export declare const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
3
|
+
export declare const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
|
|
4
|
+
export interface PluginDiagnostic {
|
|
5
|
+
readonly severity: "info" | "warning";
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly message: string;
|
|
8
|
+
readonly path?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare class PluginError extends Error {
|
|
11
|
+
readonly code: string;
|
|
12
|
+
readonly diagnostics: readonly PluginDiagnostic[];
|
|
13
|
+
constructor(code: string, message: string, diagnostics?: readonly PluginDiagnostic[]);
|
|
14
|
+
}
|
|
15
|
+
export interface LoadedPlugin {
|
|
16
|
+
readonly root: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
readonly description?: string;
|
|
19
|
+
readonly skills: Readonly<Record<string, SkillRecord>>;
|
|
20
|
+
readonly mcpServers: Readonly<Record<string, McpServerManifest>>;
|
|
21
|
+
readonly diagnostics: readonly PluginDiagnostic[];
|
|
22
|
+
}
|
|
23
|
+
export declare function loadPlugin(directory: string): LoadedPlugin;
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { expandPluginPlaceholders } from "./launch.js";
|
|
4
|
+
export const PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
|
|
5
|
+
export const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
|
|
6
|
+
const PLUGIN_FIELDS = new Set([
|
|
7
|
+
"$schema",
|
|
8
|
+
"name",
|
|
9
|
+
"version",
|
|
10
|
+
"description",
|
|
11
|
+
"author",
|
|
12
|
+
"homepage",
|
|
13
|
+
"repository",
|
|
14
|
+
"license",
|
|
15
|
+
"keywords",
|
|
16
|
+
"extensions",
|
|
17
|
+
]);
|
|
18
|
+
const AUTHOR_FIELDS = new Set(["name", "email", "url"]);
|
|
19
|
+
const RESERVED_ENV = new Set(["PLUGIN_ROOT", "PLUGIN_DATA"]);
|
|
20
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
21
|
+
export class PluginError extends Error {
|
|
22
|
+
code;
|
|
23
|
+
diagnostics;
|
|
24
|
+
constructor(code, message, diagnostics = []) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "PluginError";
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.diagnostics = diagnostics;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function loadPlugin(directory) {
|
|
32
|
+
const root = resolveRoot(directory);
|
|
33
|
+
const manifestPath = join(root, "plugin.json");
|
|
34
|
+
const manifestReal = realInside(root, manifestPath);
|
|
35
|
+
if (!manifestReal || !existsSync(manifestReal) || !statSync(manifestReal).isFile())
|
|
36
|
+
throw new PluginError("plugin.manifest-missing", `plugin.json must be a file inside ${root}`, [{ severity: "warning", code: "plugin.manifest-missing", message: "plugin.json is missing or escapes the package root", path: manifestPath }]);
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(readFileSync(manifestReal, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new PluginError("plugin.manifest-invalid", "plugin.json is not valid JSON");
|
|
43
|
+
}
|
|
44
|
+
if (!isRecord(parsed))
|
|
45
|
+
throw new PluginError("plugin.manifest-invalid", "plugin.json must be a JSON object");
|
|
46
|
+
const diagnostics = [];
|
|
47
|
+
for (const key of Object.keys(parsed)) {
|
|
48
|
+
if (!PLUGIN_FIELDS.has(key))
|
|
49
|
+
diagnostics.push({
|
|
50
|
+
severity: "warning",
|
|
51
|
+
code: "plugin.unknown-field",
|
|
52
|
+
message: `Ignored unknown plugin.json field '${key}'`,
|
|
53
|
+
path: manifestPath,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (parsed.$schema !== PLUGIN_SCHEMA)
|
|
57
|
+
throw new PluginError("plugin.schema-unsupported", "plugin.json $schema must be the Agent Plugins 1.0.0 plugin schema", diagnostics);
|
|
58
|
+
if (!isPluginName(parsed.name))
|
|
59
|
+
throw new PluginError("plugin.name-invalid", "plugin.json name must be 1-64 characters of lowercase letters, digits, hyphens, and periods", diagnostics);
|
|
60
|
+
validateMetadata(parsed, diagnostics);
|
|
61
|
+
const extensions = parsed.extensions;
|
|
62
|
+
if (extensions !== undefined && !isRecord(extensions))
|
|
63
|
+
diagnostics.push({
|
|
64
|
+
severity: "warning",
|
|
65
|
+
code: "plugin.extensions-ignored",
|
|
66
|
+
message: "Ignored non-object extensions field",
|
|
67
|
+
path: manifestPath,
|
|
68
|
+
});
|
|
69
|
+
else if (isRecord(extensions)) {
|
|
70
|
+
for (const namespace of Object.keys(extensions))
|
|
71
|
+
diagnostics.push({
|
|
72
|
+
severity: "info",
|
|
73
|
+
code: "plugin.extension-ignored",
|
|
74
|
+
message: `Ignored unimplemented extension namespace '${namespace}'`,
|
|
75
|
+
path: manifestPath,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const skills = loadSkills(root, diagnostics);
|
|
79
|
+
const mcpServers = loadMcp(root, parsed.name, diagnostics);
|
|
80
|
+
return {
|
|
81
|
+
root,
|
|
82
|
+
name: parsed.name,
|
|
83
|
+
...(typeof parsed.description === "string" ? { description: parsed.description } : {}),
|
|
84
|
+
skills,
|
|
85
|
+
mcpServers,
|
|
86
|
+
diagnostics,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function resolveRoot(directory) {
|
|
90
|
+
const resolved = resolve(directory);
|
|
91
|
+
if (!existsSync(resolved))
|
|
92
|
+
throw new PluginError("plugin.missing", `Plugin directory does not exist: ${directory}`);
|
|
93
|
+
const real = realpathSync(resolved);
|
|
94
|
+
if (!statSync(real).isDirectory())
|
|
95
|
+
throw new PluginError("plugin.missing", `Plugin path is not a directory: ${directory}`);
|
|
96
|
+
return real;
|
|
97
|
+
}
|
|
98
|
+
function validateMetadata(manifest, diagnostics) {
|
|
99
|
+
for (const field of ["version", "description", "homepage", "repository", "license"]) {
|
|
100
|
+
if (manifest[field] !== undefined && typeof manifest[field] !== "string")
|
|
101
|
+
throw new PluginError("plugin.manifest-invalid", `plugin.json ${field} must be a string`, diagnostics);
|
|
102
|
+
}
|
|
103
|
+
if (manifest.keywords !== undefined) {
|
|
104
|
+
if (!Array.isArray(manifest.keywords) || manifest.keywords.some((item) => typeof item !== "string"))
|
|
105
|
+
throw new PluginError("plugin.manifest-invalid", "plugin.json keywords must be an array of strings", diagnostics);
|
|
106
|
+
}
|
|
107
|
+
if (manifest.author !== undefined) {
|
|
108
|
+
if (!isRecord(manifest.author))
|
|
109
|
+
throw new PluginError("plugin.manifest-invalid", "plugin.json author must be an object", diagnostics);
|
|
110
|
+
for (const key of Object.keys(manifest.author)) {
|
|
111
|
+
if (!AUTHOR_FIELDS.has(key) || typeof manifest.author[key] !== "string")
|
|
112
|
+
throw new PluginError("plugin.manifest-invalid", "plugin.json author contains an invalid field", diagnostics);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function loadSkills(root, diagnostics) {
|
|
117
|
+
const location = join(root, "skills");
|
|
118
|
+
if (!existsSync(location))
|
|
119
|
+
return {};
|
|
120
|
+
const real = realInside(root, location);
|
|
121
|
+
if (!real || !statSync(real).isDirectory()) {
|
|
122
|
+
diagnostics.push({
|
|
123
|
+
severity: "warning",
|
|
124
|
+
code: "plugin.skills-invalid",
|
|
125
|
+
message: "skills/ is not a directory inside the package",
|
|
126
|
+
path: location,
|
|
127
|
+
});
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
const skills = {};
|
|
131
|
+
for (const entry of readdirSync(real, { withFileTypes: true })) {
|
|
132
|
+
if (!entry.isDirectory() || entry.name.startsWith("."))
|
|
133
|
+
continue;
|
|
134
|
+
const directory = join(real, entry.name);
|
|
135
|
+
const directoryReal = realInside(root, directory);
|
|
136
|
+
if (!directoryReal) {
|
|
137
|
+
diagnostics.push({
|
|
138
|
+
severity: "warning",
|
|
139
|
+
code: "plugin.skill-skipped",
|
|
140
|
+
message: `Skipped skill '${entry.name}' because its directory escapes the package`,
|
|
141
|
+
path: directory,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const skillFile = join(directoryReal, "SKILL.md");
|
|
146
|
+
if (!existsSync(skillFile))
|
|
147
|
+
continue;
|
|
148
|
+
const skillReal = realInside(root, skillFile);
|
|
149
|
+
if (!skillReal || !statSync(skillReal).isFile()) {
|
|
150
|
+
diagnostics.push({
|
|
151
|
+
severity: "warning",
|
|
152
|
+
code: "plugin.skill-skipped",
|
|
153
|
+
message: `Skipped skill '${entry.name}' because SKILL.md escapes the package`,
|
|
154
|
+
path: skillFile,
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const parsed = parseSkill(readFileSync(skillReal, "utf8"), entry.name);
|
|
159
|
+
if (!parsed) {
|
|
160
|
+
diagnostics.push({
|
|
161
|
+
severity: "warning",
|
|
162
|
+
code: "plugin.skill-skipped",
|
|
163
|
+
message: `Skipped skill '${entry.name}' because SKILL.md is not a valid Agent Skill`,
|
|
164
|
+
path: skillFile,
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (skills[parsed.name]) {
|
|
169
|
+
diagnostics.push({
|
|
170
|
+
severity: "warning",
|
|
171
|
+
code: "plugin.skill-skipped",
|
|
172
|
+
message: `Skipped duplicate skill '${parsed.name}'`,
|
|
173
|
+
path: skillFile,
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const resources = readResources(root, directoryReal, diagnostics);
|
|
178
|
+
skills[parsed.name] = {
|
|
179
|
+
name: parsed.name,
|
|
180
|
+
description: parsed.description,
|
|
181
|
+
instructions: parsed.instructions,
|
|
182
|
+
...(Object.keys(resources).length === 0 ? {} : { resources }),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return skills;
|
|
186
|
+
}
|
|
187
|
+
function parseSkill(text, directoryName) {
|
|
188
|
+
const match = /^-{3}\r?\n([\s\S]*?)\r?\n-{3}(?:\r?\n|$)/.exec(text);
|
|
189
|
+
if (!match)
|
|
190
|
+
return undefined;
|
|
191
|
+
const fields = {};
|
|
192
|
+
for (const raw of (match[1] ?? "").split(/\r?\n/)) {
|
|
193
|
+
if (!raw.trim() || raw.trimStart().startsWith("#"))
|
|
194
|
+
continue;
|
|
195
|
+
const line = /^([\w-]+)\s*:\s*(.*)$/.exec(raw);
|
|
196
|
+
if (!line || line[1] === "metadata")
|
|
197
|
+
continue;
|
|
198
|
+
fields[line[1]] = unquote(line[2] ?? "");
|
|
199
|
+
}
|
|
200
|
+
const name = fields.name?.trim() ?? "";
|
|
201
|
+
const description = fields.description?.trim() ?? "";
|
|
202
|
+
if (!name || !description || description.length > 1024)
|
|
203
|
+
return undefined;
|
|
204
|
+
if (name.length > 64 || !SKILL_NAME.test(name) || name !== directoryName)
|
|
205
|
+
return undefined;
|
|
206
|
+
return { name, description, instructions: text.slice(match[0].length).replace(/^\r?\n/, "") };
|
|
207
|
+
}
|
|
208
|
+
function readResources(root, directory, diagnostics) {
|
|
209
|
+
const resources = {};
|
|
210
|
+
const walk = (current, depth) => {
|
|
211
|
+
if (depth > 6)
|
|
212
|
+
return;
|
|
213
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
214
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules")
|
|
215
|
+
continue;
|
|
216
|
+
const full = join(current, entry.name);
|
|
217
|
+
if (entry.isDirectory()) {
|
|
218
|
+
const real = realInside(root, full);
|
|
219
|
+
if (!real)
|
|
220
|
+
continue;
|
|
221
|
+
walk(real, depth + 1);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!entry.isFile() && !entry.isSymbolicLink())
|
|
225
|
+
continue;
|
|
226
|
+
if (depth === 0 && entry.name === "SKILL.md")
|
|
227
|
+
continue;
|
|
228
|
+
const real = realInside(root, full);
|
|
229
|
+
if (!real || !statSync(real).isFile()) {
|
|
230
|
+
diagnostics.push({
|
|
231
|
+
severity: "warning",
|
|
232
|
+
code: "plugin.resource-denied",
|
|
233
|
+
message: `Denied resource outside the package: ${entry.name}`,
|
|
234
|
+
path: full,
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const bytes = readFileSync(real);
|
|
239
|
+
if (bytes.includes(0))
|
|
240
|
+
continue;
|
|
241
|
+
const rel = relative(directory, real).split(sep).join("/");
|
|
242
|
+
if (!rel || rel.startsWith(".."))
|
|
243
|
+
continue;
|
|
244
|
+
resources[rel] = bytes.toString("utf8");
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
walk(directory, 0);
|
|
248
|
+
return resources;
|
|
249
|
+
}
|
|
250
|
+
function loadMcp(root, pluginName, diagnostics) {
|
|
251
|
+
const location = join(root, "mcp.json");
|
|
252
|
+
if (!existsSync(location))
|
|
253
|
+
return {};
|
|
254
|
+
const real = realInside(root, location);
|
|
255
|
+
if (!real || !statSync(real).isFile()) {
|
|
256
|
+
diagnostics.push({
|
|
257
|
+
severity: "warning",
|
|
258
|
+
code: "plugin.mcp-invalid",
|
|
259
|
+
message: "mcp.json is not a file inside the package",
|
|
260
|
+
path: location,
|
|
261
|
+
});
|
|
262
|
+
return {};
|
|
263
|
+
}
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
parsed = JSON.parse(readFileSync(real, "utf8"));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
diagnostics.push({
|
|
270
|
+
severity: "warning",
|
|
271
|
+
code: "plugin.mcp-invalid",
|
|
272
|
+
message: "mcp.json is not valid JSON",
|
|
273
|
+
path: location,
|
|
274
|
+
});
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
if (!isRecord(parsed) || Object.keys(parsed).some((key) => key !== "$schema" && key !== "mcpServers")) {
|
|
278
|
+
diagnostics.push({
|
|
279
|
+
severity: "warning",
|
|
280
|
+
code: "plugin.mcp-invalid",
|
|
281
|
+
message: "mcp.json must contain only $schema and mcpServers",
|
|
282
|
+
path: location,
|
|
283
|
+
});
|
|
284
|
+
return {};
|
|
285
|
+
}
|
|
286
|
+
if (parsed.$schema !== MCP_SCHEMA) {
|
|
287
|
+
diagnostics.push({
|
|
288
|
+
severity: "warning",
|
|
289
|
+
code: "plugin.mcp-invalid",
|
|
290
|
+
message: "mcp.json $schema does not match the plugin schema version",
|
|
291
|
+
path: location,
|
|
292
|
+
});
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
if (!isRecord(parsed.mcpServers)) {
|
|
296
|
+
diagnostics.push({
|
|
297
|
+
severity: "warning",
|
|
298
|
+
code: "plugin.mcp-invalid",
|
|
299
|
+
message: "mcp.json mcpServers must be an object",
|
|
300
|
+
path: location,
|
|
301
|
+
});
|
|
302
|
+
return {};
|
|
303
|
+
}
|
|
304
|
+
const pluginData = join(dirname(root), ".nylorun", "plugin-data", pluginName);
|
|
305
|
+
const servers = {};
|
|
306
|
+
for (const [name, value] of Object.entries(parsed.mcpServers)) {
|
|
307
|
+
const server = validateServer(name, value, root, pluginData);
|
|
308
|
+
if (!server) {
|
|
309
|
+
diagnostics.push({
|
|
310
|
+
severity: "warning",
|
|
311
|
+
code: "plugin.mcp-server-skipped",
|
|
312
|
+
message: `Skipped invalid MCP server '${name}'`,
|
|
313
|
+
path: location,
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (server.type === "stdio")
|
|
318
|
+
mkdirSync(pluginData, { recursive: true });
|
|
319
|
+
servers[name] = server;
|
|
320
|
+
}
|
|
321
|
+
return servers;
|
|
322
|
+
}
|
|
323
|
+
function validateServer(name, value, root, pluginData) {
|
|
324
|
+
if (!isRecord(value) || typeof value.type !== "string")
|
|
325
|
+
return undefined;
|
|
326
|
+
if (value.type === "stdio")
|
|
327
|
+
return validateStdio(name, value, root, pluginData);
|
|
328
|
+
if (value.type === "streamable-http" || value.type === "sse")
|
|
329
|
+
return validateRemote(name, value);
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
function validateStdio(name, value, root, pluginData) {
|
|
333
|
+
const allowed = new Set(["type", "command", "args", "env", "cwd"]);
|
|
334
|
+
if (Object.keys(value).some((key) => !allowed.has(key)))
|
|
335
|
+
return undefined;
|
|
336
|
+
if (typeof value.command !== "string" || !isCommandToken(value.command))
|
|
337
|
+
return undefined;
|
|
338
|
+
if (value.command.startsWith("./") && !commandStaysInside(root, value.command))
|
|
339
|
+
return undefined;
|
|
340
|
+
let args;
|
|
341
|
+
if (value.args !== undefined) {
|
|
342
|
+
if (!Array.isArray(value.args) || value.args.some((item) => typeof item !== "string"))
|
|
343
|
+
return undefined;
|
|
344
|
+
args = value.args;
|
|
345
|
+
}
|
|
346
|
+
let env;
|
|
347
|
+
if (value.env !== undefined) {
|
|
348
|
+
if (!isRecord(value.env))
|
|
349
|
+
return undefined;
|
|
350
|
+
env = {};
|
|
351
|
+
for (const [key, item] of Object.entries(value.env)) {
|
|
352
|
+
if (typeof item !== "string" || RESERVED_ENV.has(key))
|
|
353
|
+
return undefined;
|
|
354
|
+
env[key] = item;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
let cwd;
|
|
358
|
+
if (value.cwd !== undefined) {
|
|
359
|
+
if (typeof value.cwd !== "string" || !isCwdForm(value.cwd))
|
|
360
|
+
return undefined;
|
|
361
|
+
const expanded = expandPluginPlaceholders(value.cwd, root, pluginData);
|
|
362
|
+
const absolute = expanded.startsWith("/") || /^[A-Za-z]:[\\/]/.test(expanded)
|
|
363
|
+
? expanded
|
|
364
|
+
: resolve(root, expanded);
|
|
365
|
+
const boundary = value.cwd === "${PLUGIN_DATA}" || value.cwd.startsWith("${PLUGIN_DATA}/")
|
|
366
|
+
? pluginData
|
|
367
|
+
: root;
|
|
368
|
+
if (!isInside(boundary, absolute))
|
|
369
|
+
return undefined;
|
|
370
|
+
cwd = value.cwd;
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
name,
|
|
374
|
+
type: "stdio",
|
|
375
|
+
command: value.command,
|
|
376
|
+
...(args === undefined ? {} : { args }),
|
|
377
|
+
...(env === undefined ? {} : { env }),
|
|
378
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
function validateRemote(name, value) {
|
|
382
|
+
const allowed = new Set(["type", "url", "headers"]);
|
|
383
|
+
if (Object.keys(value).some((key) => !allowed.has(key)))
|
|
384
|
+
return undefined;
|
|
385
|
+
if (typeof value.url !== "string" || !isRemoteUrl(value.url))
|
|
386
|
+
return undefined;
|
|
387
|
+
let headers;
|
|
388
|
+
if (value.headers !== undefined) {
|
|
389
|
+
if (!isRecord(value.headers))
|
|
390
|
+
return undefined;
|
|
391
|
+
const seen = new Set();
|
|
392
|
+
headers = {};
|
|
393
|
+
for (const [key, item] of Object.entries(value.headers)) {
|
|
394
|
+
if (typeof item !== "string" || !isHeaderName(key) || /[\r\n\0]/.test(item))
|
|
395
|
+
return undefined;
|
|
396
|
+
const folded = key.toLowerCase();
|
|
397
|
+
if (seen.has(folded))
|
|
398
|
+
return undefined;
|
|
399
|
+
seen.add(folded);
|
|
400
|
+
headers[key] = item;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
name,
|
|
405
|
+
type: value.type,
|
|
406
|
+
url: value.url,
|
|
407
|
+
...(headers === undefined ? {} : { headers }),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function commandStaysInside(root, command) {
|
|
411
|
+
const lexical = resolve(root, command);
|
|
412
|
+
if (!isInside(root, lexical))
|
|
413
|
+
return false;
|
|
414
|
+
return !existsSync(lexical) || realInside(root, lexical) !== undefined;
|
|
415
|
+
}
|
|
416
|
+
function isCommandToken(command) {
|
|
417
|
+
if (!command || /\s/.test(command))
|
|
418
|
+
return false;
|
|
419
|
+
if (command.startsWith("./"))
|
|
420
|
+
return !command.split("/").includes("..");
|
|
421
|
+
return !command.includes("/") && !command.includes("\\") && !command.includes("${");
|
|
422
|
+
}
|
|
423
|
+
function isCwdForm(cwd) {
|
|
424
|
+
return (cwd.startsWith("./") ||
|
|
425
|
+
cwd === "${PLUGIN_ROOT}" ||
|
|
426
|
+
cwd.startsWith("${PLUGIN_ROOT}/") ||
|
|
427
|
+
cwd === "${PLUGIN_DATA}" ||
|
|
428
|
+
cwd.startsWith("${PLUGIN_DATA}/"));
|
|
429
|
+
}
|
|
430
|
+
function isRemoteUrl(value) {
|
|
431
|
+
let url;
|
|
432
|
+
try {
|
|
433
|
+
url = new URL(value);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
if (url.username || url.password || url.hash)
|
|
439
|
+
return false;
|
|
440
|
+
if (url.protocol === "https:")
|
|
441
|
+
return true;
|
|
442
|
+
if (url.protocol !== "http:")
|
|
443
|
+
return false;
|
|
444
|
+
return isLoopback(url.hostname);
|
|
445
|
+
}
|
|
446
|
+
function isLoopback(hostname) {
|
|
447
|
+
const host = hostname.replace(/^\[|\]$/g, "");
|
|
448
|
+
if (host === "localhost" || host === "::1")
|
|
449
|
+
return true;
|
|
450
|
+
const match = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
451
|
+
if (!match)
|
|
452
|
+
return false;
|
|
453
|
+
return match.slice(1).every((part) => Number(part) <= 255);
|
|
454
|
+
}
|
|
455
|
+
function isHeaderName(name) {
|
|
456
|
+
return /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
|
|
457
|
+
}
|
|
458
|
+
function isPluginName(value) {
|
|
459
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 64)
|
|
460
|
+
return false;
|
|
461
|
+
if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(value) && value.length !== 1)
|
|
462
|
+
return false;
|
|
463
|
+
if (value.length === 1)
|
|
464
|
+
return /^[a-z0-9]$/.test(value);
|
|
465
|
+
return !value.includes("--") && !value.includes("..");
|
|
466
|
+
}
|
|
467
|
+
function realInside(root, candidate) {
|
|
468
|
+
if (!existsSync(candidate))
|
|
469
|
+
return undefined;
|
|
470
|
+
try {
|
|
471
|
+
const real = realpathSync(candidate);
|
|
472
|
+
return isInside(root, real) ? real : undefined;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function isInside(root, target) {
|
|
479
|
+
const rel = relative(root, target);
|
|
480
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
481
|
+
}
|
|
482
|
+
function isRecord(value) {
|
|
483
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
484
|
+
}
|
|
485
|
+
function unquote(value) {
|
|
486
|
+
const trimmed = value.trim();
|
|
487
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
488
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")))
|
|
489
|
+
return trimmed.slice(1, -1);
|
|
490
|
+
return trimmed;
|
|
491
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CapabilityDeclaration } from "@nylorun/core/define";
|
|
2
|
+
import { type LoadedPlugin, type PluginDiagnostic } from "./load.js";
|
|
3
|
+
export interface PluginCapability extends CapabilityDeclaration {
|
|
4
|
+
readonly diagnostics: readonly PluginDiagnostic[];
|
|
5
|
+
}
|
|
6
|
+
/** Read an Agent Plugin package and return one capability for `Agent.use`. */
|
|
7
|
+
export declare function plugin(directory: string): PluginCapability;
|
|
8
|
+
export declare function capabilityFromPlugin(loaded: LoadedPlugin): PluginCapability;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { loadPlugin, } from "./load.js";
|
|
2
|
+
const SKILL_USAGE = "The following skills provide specialized instructions. When a task matches a skill description, call load_skill with that name before proceeding.";
|
|
3
|
+
/** Read an Agent Plugin package and return one capability for `Agent.use`. */
|
|
4
|
+
export function plugin(directory) {
|
|
5
|
+
const loaded = loadPlugin(directory);
|
|
6
|
+
return capabilityFromPlugin(loaded);
|
|
7
|
+
}
|
|
8
|
+
export function capabilityFromPlugin(loaded) {
|
|
9
|
+
const names = Object.values(loaded.skills);
|
|
10
|
+
const instructions = names.length === 0
|
|
11
|
+
? undefined
|
|
12
|
+
: [SKILL_USAGE, formatCatalog(names.map((skill) => ({
|
|
13
|
+
name: skill.name,
|
|
14
|
+
description: skill.description,
|
|
15
|
+
})))];
|
|
16
|
+
const skills = Object.fromEntries(names.map((skill) => [
|
|
17
|
+
skill.name,
|
|
18
|
+
{ name: skill.name, description: skill.description },
|
|
19
|
+
]));
|
|
20
|
+
return {
|
|
21
|
+
id: loaded.name,
|
|
22
|
+
type: "agent-plugin",
|
|
23
|
+
pluginRoot: loaded.root,
|
|
24
|
+
...(loaded.description === undefined ? {} : { description: loaded.description }),
|
|
25
|
+
...(instructions === undefined ? {} : { instructions }),
|
|
26
|
+
...(names.length === 0 ? {} : { skills, skillRecords: loaded.skills }),
|
|
27
|
+
...(Object.keys(loaded.mcpServers).length === 0
|
|
28
|
+
? {}
|
|
29
|
+
: { mcpServers: loaded.mcpServers }),
|
|
30
|
+
diagnostics: loaded.diagnostics,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function formatCatalog(skills) {
|
|
34
|
+
const entries = skills
|
|
35
|
+
.map((skill) => ` <skill>\n <name>${escapeXml(skill.name)}</name>\n <description>${escapeXml(skill.description)}</description>\n </skill>`)
|
|
36
|
+
.join("\n");
|
|
37
|
+
return `<available_skills>\n${entries}\n</available_skills>`;
|
|
38
|
+
}
|
|
39
|
+
function escapeXml(value) {
|
|
40
|
+
return value
|
|
41
|
+
.replaceAll("&", "&")
|
|
42
|
+
.replaceAll("<", "<")
|
|
43
|
+
.replaceAll(">", ">")
|
|
44
|
+
.replaceAll('"', """);
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nylorun/agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-beta",
|
|
4
4
|
"description": "Nylorun agent definitions, session client and connected executor",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -35,15 +35,15 @@
|
|
|
35
35
|
"test": "vitest run --passWithNoTests"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@nylorun/core": "0.
|
|
38
|
+
"@nylorun/core": "0.2.0-beta"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
-
"zod": "^4.5
|
|
41
|
+
"zod": "^4.6.5"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
45
44
|
"@types/node": "^24.0.0",
|
|
46
|
-
"
|
|
45
|
+
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
|
46
|
+
"vitest": "^5.0.1"
|
|
47
47
|
},
|
|
48
48
|
"engines": {
|
|
49
49
|
"node": ">=24"
|