@easyai-canvas/canvas-plugin-sdk 0.1.0
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/LICENSE +5 -0
- package/README.md +22 -0
- package/bin/easyai-plugin.mjs +267 -0
- package/dist/customer-main.d.ts +1 -0
- package/dist/customer-main.js +256 -0
- package/dist/protocol/index.d.ts +295 -0
- package/dist/protocol/index.js +454 -0
- package/dist/runtime/auth.d.ts +1 -0
- package/dist/runtime/auth.js +68 -0
- package/dist/runtime/engine.d.ts +49 -0
- package/dist/runtime/engine.js +296 -0
- package/dist/runtime/host-files.d.ts +16 -0
- package/dist/runtime/host-files.js +208 -0
- package/dist/runtime/instance-lock.d.ts +1 -0
- package/dist/runtime/instance-lock.js +52 -0
- package/dist/runtime/package-runtime.d.ts +44 -0
- package/dist/runtime/package-runtime.js +361 -0
- package/dist/runtime/packaged-plugins.d.ts +14 -0
- package/dist/runtime/packaged-plugins.js +58 -0
- package/dist/runtime/plugin-config.d.ts +4 -0
- package/dist/runtime/plugin-config.js +31 -0
- package/dist/runtime/store.d.ts +48 -0
- package/dist/runtime/store.js +151 -0
- package/dist/runtime/worker.d.ts +1 -0
- package/dist/runtime/worker.js +38 -0
- package/dist/sdk/frontend.d.ts +62 -0
- package/dist/sdk/frontend.js +40 -0
- package/dist/sdk/index.d.ts +38 -0
- package/dist/sdk/index.js +10 -0
- package/dist/studio/index.html +649 -0
- package/dist/studio/remote-connector.d.ts +60 -0
- package/dist/studio/remote-connector.js +295 -0
- package/package.json +45 -0
- package/tools/build-plugin-package.mjs +99 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/** Public, dependency-free contract. Host copies are generated by protocol:sync. */
|
|
2
|
+
export const PROTOCOL = 'easyai.plugin/v1';
|
|
3
|
+
function cloneDeclaration(value) {
|
|
4
|
+
if (Array.isArray(value))
|
|
5
|
+
return value.map(item => cloneDeclaration(item));
|
|
6
|
+
if (value && typeof value === 'object')
|
|
7
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneDeclaration(item)]));
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
export class ContractError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
code;
|
|
13
|
+
constructor(message, status = 400, code = 'INVALID_REQUEST') {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = 'ContractError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export function object(value, path = 'data') {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
22
|
+
throw new ContractError(`${path} 必须是对象`);
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
function keys(value, allowed, path) {
|
|
26
|
+
const result = object(value, path);
|
|
27
|
+
for (const key of Object.keys(result))
|
|
28
|
+
if (!allowed.includes(key))
|
|
29
|
+
throw new ContractError(`${path}.${key} 不支持`);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
function optionalString(value, path) {
|
|
33
|
+
if (value === undefined)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (typeof value !== 'string' || !value.trim())
|
|
36
|
+
throw new ContractError(`${path} 无效`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
export function validatePluginRuntimeContext(value) {
|
|
40
|
+
const context = keys(value, ['user', 'projectId', 'nodeId', 'environment'], 'context');
|
|
41
|
+
const user = keys(context.user, ['id', 'username', 'ssoId', 'tenantId'], 'context.user');
|
|
42
|
+
const id = optionalString(user.id, 'context.user.id');
|
|
43
|
+
if (!id)
|
|
44
|
+
throw new ContractError('调用身份缺失');
|
|
45
|
+
if (!['canvas'].includes(String(context.environment)))
|
|
46
|
+
throw new ContractError('运行环境无效');
|
|
47
|
+
optionalString(user.username, 'context.user.username');
|
|
48
|
+
optionalString(user.ssoId, 'context.user.ssoId');
|
|
49
|
+
optionalString(user.tenantId, 'context.user.tenantId');
|
|
50
|
+
optionalString(context.projectId, 'context.projectId');
|
|
51
|
+
optionalString(context.nodeId, 'context.nodeId');
|
|
52
|
+
return cloneDeclaration(context);
|
|
53
|
+
}
|
|
54
|
+
function validateMenu(value, path) {
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return;
|
|
57
|
+
const menu = keys(value, ['group', 'order', 'icon'], path);
|
|
58
|
+
if (!['basic', 'storyboard', 'tools', 'resources'].includes(String(menu.group)))
|
|
59
|
+
throw new ContractError(`${path} 菜单分组无效`);
|
|
60
|
+
if (menu.order !== undefined && (typeof menu.order !== 'number' || !Number.isFinite(menu.order)))
|
|
61
|
+
throw new ContractError(`${path} 菜单顺序无效`);
|
|
62
|
+
if (menu.icon !== undefined && typeof menu.icon !== 'string')
|
|
63
|
+
throw new ContractError(`${path} 菜单图标无效`);
|
|
64
|
+
}
|
|
65
|
+
function optionalBoolean(value, path) {
|
|
66
|
+
if (value !== undefined && typeof value !== 'boolean')
|
|
67
|
+
throw new ContractError(`${path} 必须是布尔值`);
|
|
68
|
+
}
|
|
69
|
+
function boundedInteger(value, path, min, max) {
|
|
70
|
+
if (value !== undefined && (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max))
|
|
71
|
+
throw new ContractError(`${path} 范围无效`);
|
|
72
|
+
}
|
|
73
|
+
function validateNodeCapabilities(value) {
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
return;
|
|
76
|
+
const capabilities = keys(value, ['documentState', 'workspace', 'mediaUpload', 'inputManagement', 'outputToCanvas'], 'node.capabilities');
|
|
77
|
+
for (const key of ['workspace', 'mediaUpload', 'inputManagement', 'outputToCanvas'])
|
|
78
|
+
optionalBoolean(capabilities[key], `node.capabilities.${key}`);
|
|
79
|
+
if (capabilities.documentState !== undefined) {
|
|
80
|
+
const document = keys(capabilities.documentState, ['key', 'schema', 'maxBytes', 'renderedRevisionKey', 'resultKey', 'subtitleResultKey'], 'node.capabilities.documentState');
|
|
81
|
+
identifier(document.key, 'node.capabilities.documentState.key');
|
|
82
|
+
optionalString(document.schema, 'node.capabilities.documentState.schema');
|
|
83
|
+
boundedInteger(document.maxBytes, 'node.capabilities.documentState 文档大小', 1_024, 10_000_000);
|
|
84
|
+
if (document.renderedRevisionKey !== undefined)
|
|
85
|
+
identifier(document.renderedRevisionKey, 'node.capabilities.documentState.renderedRevisionKey');
|
|
86
|
+
if (document.resultKey !== undefined)
|
|
87
|
+
identifier(document.resultKey, 'node.capabilities.documentState.resultKey');
|
|
88
|
+
if (document.subtitleResultKey !== undefined)
|
|
89
|
+
identifier(document.subtitleResultKey, 'node.capabilities.documentState.subtitleResultKey');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function validateNodeRuntime(value) {
|
|
93
|
+
if (value === undefined)
|
|
94
|
+
return;
|
|
95
|
+
const runtime = keys(value, ['safeRetry', 'maxAttempts', 'maxQueuedPerUser', 'maxQueued'], 'node.runtime');
|
|
96
|
+
optionalBoolean(runtime.safeRetry, 'node.runtime.safeRetry');
|
|
97
|
+
boundedInteger(runtime.maxAttempts, 'node.runtime 重试次数', 1, 5);
|
|
98
|
+
boundedInteger(runtime.maxQueuedPerUser, 'node.runtime 用户排队上限', 1, 100);
|
|
99
|
+
boundedInteger(runtime.maxQueued, 'node.runtime 全局排队上限', 1, 10_000);
|
|
100
|
+
if (!runtime.safeRetry && runtime.maxAttempts !== undefined && Number(runtime.maxAttempts) > 1)
|
|
101
|
+
throw new ContractError('未声明安全重试的节点不能配置多次重试');
|
|
102
|
+
}
|
|
103
|
+
export function identifier(value, path) {
|
|
104
|
+
if (typeof value !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,159}$/.test(value))
|
|
105
|
+
throw new ContractError(`${path} 标识无效`);
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
function validateFields(value) {
|
|
109
|
+
if (!Array.isArray(value))
|
|
110
|
+
throw new ContractError('配置字段必须是数组');
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
for (const raw of value) {
|
|
113
|
+
const field = keys(raw, ['key', 'label', 'type', 'default', 'options', 'required'], '配置');
|
|
114
|
+
const key = identifier(field.key, '配置字段');
|
|
115
|
+
if (seen.has(key) || ['__proto__', 'constructor', 'prototype'].includes(key))
|
|
116
|
+
throw new ContractError('配置字段重复或无效');
|
|
117
|
+
seen.add(key);
|
|
118
|
+
if (typeof field.label !== 'string' || !['string', 'secret', 'number', 'boolean', 'select'].includes(String(field.type)))
|
|
119
|
+
throw new ContractError('配置字段类型无效');
|
|
120
|
+
if (field.required !== undefined && typeof field.required !== 'boolean')
|
|
121
|
+
throw new ContractError('配置 required 无效');
|
|
122
|
+
if (field.type === 'select' && (!Array.isArray(field.options) || field.options.some(o => !o || typeof o.value !== 'string' || typeof o.label !== 'string') || new Set(field.options.map(o => o.value)).size !== field.options.length))
|
|
123
|
+
throw new ContractError('配置选项无效');
|
|
124
|
+
if (field.default !== undefined)
|
|
125
|
+
validateParameters([raw], { [key]: field.default });
|
|
126
|
+
}
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
export function validateParameters(fields, input) {
|
|
130
|
+
const result = { ...input };
|
|
131
|
+
for (const f of fields) {
|
|
132
|
+
const value = result[f.key] === undefined ? f.default : result[f.key];
|
|
133
|
+
if (value === undefined) {
|
|
134
|
+
if (f.required)
|
|
135
|
+
throw new ContractError(`缺少参数 ${f.key}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (f.type === 'number' ? typeof value !== 'number' || !Number.isFinite(value) : f.type === 'boolean' ? typeof value !== 'boolean' : typeof value !== 'string')
|
|
139
|
+
throw new ContractError(`参数 ${f.key} 类型错误`);
|
|
140
|
+
if (f.type === 'select' && !f.options?.some(o => o.value === value))
|
|
141
|
+
throw new ContractError(`参数 ${f.key} 选项无效`);
|
|
142
|
+
result[f.key] = value;
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
/** Returns the interaction-oriented view while preserving legacy asset source manifests. */
|
|
147
|
+
export function listPluginActions(manifest) {
|
|
148
|
+
const actions = (manifest.contributes.actions ?? []).map(action => cloneDeclaration(action));
|
|
149
|
+
const ids = new Set(actions.map(action => action.id));
|
|
150
|
+
for (const source of manifest.contributes.assetSources ?? []) {
|
|
151
|
+
if (ids.has(source.id))
|
|
152
|
+
continue;
|
|
153
|
+
actions.push({
|
|
154
|
+
id: source.id,
|
|
155
|
+
name: source.name,
|
|
156
|
+
...(source.description ? { description: source.description } : {}),
|
|
157
|
+
action: 'asset-selection',
|
|
158
|
+
presentation: 'dialog',
|
|
159
|
+
mediaTypes: source.mediaTypes.filter((type) => ['image', 'video', 'audio', 'file'].includes(type)),
|
|
160
|
+
ui: source.ui?.selector
|
|
161
|
+
? { component: source.ui.selector }
|
|
162
|
+
: { url: source.url, origin: source.origin },
|
|
163
|
+
menu: source.menu ?? { group: 'resources' },
|
|
164
|
+
legacyAssetSource: true,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
return actions;
|
|
168
|
+
}
|
|
169
|
+
const portTypes = ['text', 'json', 'image', 'video', 'audio', 'file', 'any'];
|
|
170
|
+
export function validateManifest(value) {
|
|
171
|
+
const root = keys(value, ['protocol', 'id', 'name', 'release', 'configFields', 'contributes'], 'manifest');
|
|
172
|
+
if (root.protocol !== PROTOCOL)
|
|
173
|
+
throw new ContractError('插件协议不兼容');
|
|
174
|
+
identifier(root.id, 'pluginId');
|
|
175
|
+
if (typeof root.name !== 'string' || !root.name || typeof root.release !== 'string')
|
|
176
|
+
throw new ContractError('插件名称或发布标识无效');
|
|
177
|
+
if (root.configFields !== undefined)
|
|
178
|
+
validateFields(root.configFields);
|
|
179
|
+
const c = keys(root.contributes, ['nodes', 'actions', 'assetSources'], 'contributes');
|
|
180
|
+
if (c.nodes !== undefined) {
|
|
181
|
+
if (!Array.isArray(c.nodes))
|
|
182
|
+
throw new ContractError('nodes 必须为数组');
|
|
183
|
+
const names = new Set();
|
|
184
|
+
for (const raw of c.nodes) {
|
|
185
|
+
const node = keys(raw, ['type', 'name', 'description', 'inputs', 'outputs', 'parameters', 'execution', 'ui', 'defaults', 'timeoutMs', 'menu', 'capabilities', 'runtime'], 'node');
|
|
186
|
+
const id = identifier(node.type, 'node.type');
|
|
187
|
+
if (names.has(id))
|
|
188
|
+
throw new ContractError('节点标识重复');
|
|
189
|
+
names.add(id);
|
|
190
|
+
if (typeof node.name !== 'string' || !['remote', 'source', 'display', 'confirmed'].includes(String(node.execution)))
|
|
191
|
+
throw new ContractError('节点名称或执行能力无效');
|
|
192
|
+
if (node.parameters !== undefined)
|
|
193
|
+
validateFields(node.parameters);
|
|
194
|
+
validateMenu(node.menu, 'node.menu');
|
|
195
|
+
validateNodeCapabilities(node.capabilities);
|
|
196
|
+
validateNodeRuntime(node.runtime);
|
|
197
|
+
if (node.timeoutMs !== undefined && (typeof node.timeoutMs !== 'number' || !Number.isInteger(node.timeoutMs) || node.timeoutMs < 100 || node.timeoutMs > 3_600_000))
|
|
198
|
+
throw new ContractError('任务超时范围无效');
|
|
199
|
+
for (const direction of ['inputs', 'outputs']) {
|
|
200
|
+
const ports = node[direction];
|
|
201
|
+
if (!Array.isArray(ports))
|
|
202
|
+
throw new ContractError('端口必须为数组');
|
|
203
|
+
const ids = new Set();
|
|
204
|
+
for (const p of ports) {
|
|
205
|
+
const port = object(p);
|
|
206
|
+
const portId = identifier(port.id, 'port.id');
|
|
207
|
+
if (ids.has(portId) || !portTypes.includes(port.type))
|
|
208
|
+
throw new ContractError('端口标识重复或类型无效');
|
|
209
|
+
ids.add(portId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (c.actions !== undefined) {
|
|
215
|
+
if (!Array.isArray(c.actions))
|
|
216
|
+
throw new ContractError('actions 必须为数组');
|
|
217
|
+
const actionIds = new Set();
|
|
218
|
+
for (const raw of c.actions) {
|
|
219
|
+
const action = keys(raw, ['id', 'name', 'description', 'action', 'presentation', 'mediaTypes', 'maxCount', 'ui', 'menu'], 'action');
|
|
220
|
+
const actionId = identifier(action.id, 'action.id');
|
|
221
|
+
if (actionIds.has(actionId))
|
|
222
|
+
throw new ContractError('操作标识重复');
|
|
223
|
+
actionIds.add(actionId);
|
|
224
|
+
if (typeof action.name !== 'string' || !action.name.trim())
|
|
225
|
+
throw new ContractError('操作名称无效');
|
|
226
|
+
if (action.action !== 'asset-selection')
|
|
227
|
+
throw new ContractError('操作类型无效');
|
|
228
|
+
if (action.presentation !== 'dialog')
|
|
229
|
+
throw new ContractError('操作展示方式无效');
|
|
230
|
+
if (!Array.isArray(action.mediaTypes) || !action.mediaTypes.length || action.mediaTypes.some(type => !['image', 'video', 'audio', 'file'].includes(String(type))))
|
|
231
|
+
throw new ContractError('操作素材类型无效');
|
|
232
|
+
boundedInteger(action.maxCount, '操作素材数量', 1, 256);
|
|
233
|
+
validateMenu(action.menu, 'action.menu');
|
|
234
|
+
const ui = keys(action.ui, ['component', 'url', 'origin'], 'action.ui');
|
|
235
|
+
const component = optionalString(ui.component, 'action.ui.component');
|
|
236
|
+
if (component)
|
|
237
|
+
identifier(component, 'action.ui.component');
|
|
238
|
+
if (!component && ui.url === undefined)
|
|
239
|
+
throw new ContractError('操作必须提供界面入口');
|
|
240
|
+
if (component && (ui.url !== undefined || ui.origin !== undefined))
|
|
241
|
+
throw new ContractError('操作界面入口不能同时使用组件和 iframe');
|
|
242
|
+
if (ui.url !== undefined) {
|
|
243
|
+
let url;
|
|
244
|
+
try {
|
|
245
|
+
url = new URL(String(ui.url));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
throw new ContractError('操作界面地址无效');
|
|
249
|
+
}
|
|
250
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.origin !== ui.origin || url.username || url.password)
|
|
251
|
+
throw new ContractError('操作界面地址无效');
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (c.assetSources !== undefined) {
|
|
256
|
+
if (!Array.isArray(c.assetSources))
|
|
257
|
+
throw new ContractError('assetSources 必须为数组');
|
|
258
|
+
const sourceIds = new Set();
|
|
259
|
+
for (const raw of c.assetSources) {
|
|
260
|
+
const source = object(raw, 'assetSource');
|
|
261
|
+
if (sourceIds.has(String(source.id)))
|
|
262
|
+
throw new ContractError('素材来源标识重复');
|
|
263
|
+
sourceIds.add(String(source.id));
|
|
264
|
+
if (typeof source.name !== 'string' || !Array.isArray(source.mediaTypes) || !source.mediaTypes.length || source.mediaTypes.some(t => !['image', 'video', 'audio', 'file'].includes(t)))
|
|
265
|
+
throw new ContractError('素材来源类型无效');
|
|
266
|
+
identifier(source.id, 'source.id');
|
|
267
|
+
validateMenu(source.menu, 'assetSource.menu');
|
|
268
|
+
const ui = source.ui === undefined ? undefined : keys(source.ui, ['selector'], 'assetSource.ui');
|
|
269
|
+
if (ui)
|
|
270
|
+
identifier(ui.selector, 'assetSource.ui.selector');
|
|
271
|
+
if (source.url === undefined && !ui)
|
|
272
|
+
throw new ContractError('素材来源必须提供地址或组件');
|
|
273
|
+
if (source.url !== undefined) {
|
|
274
|
+
let url;
|
|
275
|
+
try {
|
|
276
|
+
url = new URL(String(source.url));
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
throw new ContractError('素材选择器地址无效');
|
|
280
|
+
}
|
|
281
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.origin !== source.origin || url.username || url.password)
|
|
282
|
+
throw new ContractError('素材选择器地址无效');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return cloneDeclaration(root);
|
|
287
|
+
}
|
|
288
|
+
export function validatePacket(value, expected = 'any') {
|
|
289
|
+
const packet = object(value, 'packet');
|
|
290
|
+
if (!portTypes.includes(packet.type) || (expected !== 'any' && packet.type !== expected))
|
|
291
|
+
throw new ContractError('数据类型与端口不匹配');
|
|
292
|
+
const payload = object(packet.payload, 'payload');
|
|
293
|
+
if (packet.type === 'json') {
|
|
294
|
+
if (payload.data === undefined)
|
|
295
|
+
throw new ContractError('JSON 数据缺失');
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
if (!Array.isArray(payload.items) || payload.items.length > 256)
|
|
299
|
+
throw new ContractError('数据项无效或过多');
|
|
300
|
+
for (const raw of payload.items) {
|
|
301
|
+
const item = object(raw, 'payload.items');
|
|
302
|
+
if (packet.type === 'text') {
|
|
303
|
+
if (typeof item.content !== 'string')
|
|
304
|
+
throw new ContractError('文本内容无效');
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
let url;
|
|
308
|
+
try {
|
|
309
|
+
url = new URL(String(item.url));
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
throw new ContractError('资源地址无效');
|
|
313
|
+
}
|
|
314
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
|
|
315
|
+
throw new ContractError('资源地址无效');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return packet;
|
|
320
|
+
}
|
|
321
|
+
export function validateExecution(value, node) {
|
|
322
|
+
const request = keys(value, ['executionId', 'pluginId', 'nodeType', 'parameters', 'inputsByPort', 'document', 'context', 'host'], 'execution');
|
|
323
|
+
identifier(request.executionId, 'executionId');
|
|
324
|
+
identifier(request.pluginId, 'pluginId');
|
|
325
|
+
if (request.nodeType !== node.type)
|
|
326
|
+
throw new ContractError('节点类型不匹配');
|
|
327
|
+
object(request.parameters, 'parameters');
|
|
328
|
+
const context = object(request.context, 'context');
|
|
329
|
+
identifier(context.nodeId, 'nodeId');
|
|
330
|
+
if (typeof context.userId !== 'string' || !context.userId)
|
|
331
|
+
throw new ContractError('调用身份缺失');
|
|
332
|
+
if (context.runtime !== undefined)
|
|
333
|
+
validatePluginRuntimeContext(context.runtime);
|
|
334
|
+
if (request.host !== undefined) {
|
|
335
|
+
const host = keys(request.host, ['files'], 'execution.host');
|
|
336
|
+
if (host.files !== undefined) {
|
|
337
|
+
const files = keys(host.files, ['baseUrl', 'grant', 'expiresAt', 'path'], 'execution.host.files');
|
|
338
|
+
let url;
|
|
339
|
+
try {
|
|
340
|
+
url = new URL(String(files.baseUrl));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
throw new ContractError('宿主文件能力地址无效');
|
|
344
|
+
}
|
|
345
|
+
const loopbackHttp = url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(url.hostname);
|
|
346
|
+
if ((url.protocol !== 'https:' && !loopbackHttp) || url.username || url.password || url.search || url.hash)
|
|
347
|
+
throw new ContractError('宿主文件能力地址必须是 HTTPS 或本机回环地址');
|
|
348
|
+
if (files.path !== undefined && !['/v1/plugin-artifacts'].includes(String(files.path)))
|
|
349
|
+
throw new ContractError('宿主文件能力路径无效');
|
|
350
|
+
if (typeof files.grant !== 'string' || !files.grant || files.grant.length > 8_192)
|
|
351
|
+
throw new ContractError('宿主文件能力授权无效');
|
|
352
|
+
const now = Date.now();
|
|
353
|
+
if (!Number.isSafeInteger(files.expiresAt) || Number(files.expiresAt) <= now || Number(files.expiresAt) > now + 2 * 60 * 60 * 1_000)
|
|
354
|
+
throw new ContractError('宿主文件能力授权有效期无效');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (request.document !== undefined) {
|
|
358
|
+
if (!node.capabilities?.documentState)
|
|
359
|
+
throw new ContractError('节点不接受文档快照');
|
|
360
|
+
const document = keys(request.document, ['key', 'schema', 'revision', 'value'], 'document');
|
|
361
|
+
if (document.key !== node.capabilities.documentState.key)
|
|
362
|
+
throw new ContractError('文档状态字段不匹配');
|
|
363
|
+
if (node.capabilities.documentState.schema && document.schema !== node.capabilities.documentState.schema)
|
|
364
|
+
throw new ContractError('文档格式不匹配');
|
|
365
|
+
optionalString(document.revision, 'document.revision');
|
|
366
|
+
const bytes = new TextEncoder().encode(JSON.stringify(document.value)).byteLength;
|
|
367
|
+
if (bytes > (node.capabilities.documentState.maxBytes ?? 1_000_000))
|
|
368
|
+
throw new ContractError('文档快照超过大小限制');
|
|
369
|
+
}
|
|
370
|
+
const inputs = object(request.inputsByPort, 'inputsByPort');
|
|
371
|
+
for (const id of Object.keys(inputs))
|
|
372
|
+
if (!node.inputs.some(p => p.id === id))
|
|
373
|
+
throw new ContractError(`未知输入端口 ${id}`);
|
|
374
|
+
for (const port of node.inputs) {
|
|
375
|
+
const packets = inputs[port.id] ?? [];
|
|
376
|
+
if (!Array.isArray(packets) || (port.required && packets.length === 0) || (!port.multiple && packets.length > 1))
|
|
377
|
+
throw new ContractError(`输入端口 ${port.id} 数量不符`);
|
|
378
|
+
packets.forEach(packet => validatePacket(packet, port.type));
|
|
379
|
+
const count = packets.reduce((total, packet) => total + (Array.isArray(packet.payload.items) ? packet.payload.items.length : 1), 0);
|
|
380
|
+
if ((!port.multiple && count > 1) || (port.required && count === 0))
|
|
381
|
+
throw new ContractError(`输入端口 ${port.id} 数据项数量不符`);
|
|
382
|
+
}
|
|
383
|
+
return request;
|
|
384
|
+
}
|
|
385
|
+
export function validateResult(value, node) {
|
|
386
|
+
const root = keys(value, ['outputs', 'parameters', 'documentRevision', 'phase'], 'result');
|
|
387
|
+
const outputs = object(root.outputs, 'outputs');
|
|
388
|
+
for (const id of Object.keys(outputs)) {
|
|
389
|
+
const port = node.outputs.find(p => p.id === id);
|
|
390
|
+
if (!port)
|
|
391
|
+
throw new ContractError(`未知输出端口 ${id}`);
|
|
392
|
+
validatePacket(outputs[id], port.type);
|
|
393
|
+
}
|
|
394
|
+
for (const port of node.outputs)
|
|
395
|
+
if (port.required !== false && outputs[port.id] === undefined)
|
|
396
|
+
throw new ContractError(`缺少输出端口 ${port.id}`);
|
|
397
|
+
optionalString(root.documentRevision, 'result.documentRevision');
|
|
398
|
+
optionalString(root.phase, 'result.phase');
|
|
399
|
+
return root;
|
|
400
|
+
}
|
|
401
|
+
export function validateSelectedResources(resources, allowedTypes, limit) {
|
|
402
|
+
if (!Array.isArray(resources) || resources.length > limit)
|
|
403
|
+
throw new ContractError('素材数量超过限制');
|
|
404
|
+
return resources.map(raw => {
|
|
405
|
+
const item = object(raw);
|
|
406
|
+
if (!allowedTypes.includes(String(item.type)))
|
|
407
|
+
throw new ContractError('素材类型不符合当前字段要求');
|
|
408
|
+
validatePacket({ type: item.type, payload: { items: [item] } });
|
|
409
|
+
for (const key of ['width', 'height', 'size', 'duration'])
|
|
410
|
+
if (item[key] !== undefined && (typeof item[key] !== 'number' || !Number.isFinite(item[key]) || Number(item[key]) < 0))
|
|
411
|
+
throw new ContractError('素材信息无效');
|
|
412
|
+
return cloneDeclaration(item);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/** One outstanding iframe selection. A caller must cancel on close/unmount. */
|
|
416
|
+
export class AssetSelectionSession {
|
|
417
|
+
requestId;
|
|
418
|
+
origin;
|
|
419
|
+
source;
|
|
420
|
+
allowedTypes;
|
|
421
|
+
limit;
|
|
422
|
+
closed = false;
|
|
423
|
+
constructor(requestId, origin, source, allowedTypes, limit) {
|
|
424
|
+
this.requestId = requestId;
|
|
425
|
+
this.origin = origin;
|
|
426
|
+
this.source = source;
|
|
427
|
+
this.allowedTypes = allowedTypes;
|
|
428
|
+
this.limit = limit;
|
|
429
|
+
}
|
|
430
|
+
cancel() { this.closed = true; }
|
|
431
|
+
receive(event) {
|
|
432
|
+
if (this.closed || event.origin !== this.origin || event.source !== this.source)
|
|
433
|
+
return;
|
|
434
|
+
const message = object(event.data);
|
|
435
|
+
if (message.requestId !== this.requestId)
|
|
436
|
+
return;
|
|
437
|
+
if (message.type === 'easyai.assets.cancelled') {
|
|
438
|
+
this.cancel();
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
if (message.type !== 'easyai.assets.selected')
|
|
442
|
+
return;
|
|
443
|
+
const result = validateSelectedResources(message.resources, this.allowedTypes, this.limit);
|
|
444
|
+
this.closed = true;
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/** Confirmation binds semantic input content, not transport timestamps or object key order. */
|
|
449
|
+
export function confirmationPayload(parameters, inputs) {
|
|
450
|
+
const stable = (value) => Array.isArray(value) ? value.map(stable)
|
|
451
|
+
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, stable(item)])) : value;
|
|
452
|
+
const packets = Object.fromEntries(Object.entries(inputs).map(([port, values]) => [port, values.map(({ type, payload, source }) => ({ type, payload, ...(source ? { source: { nodeId: source.nodeId, portId: source.portId } } : {}) }))]));
|
|
453
|
+
return JSON.stringify(stable({ parameters, inputs: packets }));
|
|
454
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function authenticate(header: string | undefined, admin?: boolean): Promise<void>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
function fail(message) { throw new Error(message); }
|
|
3
|
+
function decodePart(value) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return fail('调用凭证格式无效');
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function verifyToken(token) {
|
|
12
|
+
if (token.length > 8_192)
|
|
13
|
+
fail('调用凭证格式无效');
|
|
14
|
+
const [encodedHeader, encodedClaims, signature, extra] = token.split('.');
|
|
15
|
+
if (!encodedHeader || !encodedClaims || !signature || extra)
|
|
16
|
+
fail('调用凭证格式无效');
|
|
17
|
+
const header = decodePart(encodedHeader);
|
|
18
|
+
if (header.alg !== 'HS256' || header.typ !== 'JWT')
|
|
19
|
+
fail('调用凭证算法无效');
|
|
20
|
+
const secret = process.env.PLUGIN_SERVICE_SIGNING_SECRET ?? '';
|
|
21
|
+
if (Buffer.byteLength(secret) < 32)
|
|
22
|
+
fail('插件服务调用凭证未配置');
|
|
23
|
+
const expected = createHmac('sha256', secret).update(`${encodedHeader}.${encodedClaims}`).digest();
|
|
24
|
+
let actual;
|
|
25
|
+
try {
|
|
26
|
+
actual = Buffer.from(signature, 'base64url');
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return fail('调用凭证签名无效');
|
|
30
|
+
}
|
|
31
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
|
32
|
+
fail('调用凭证签名无效');
|
|
33
|
+
const claims = decodePart(encodedClaims);
|
|
34
|
+
const now = Math.floor(Date.now() / 1000);
|
|
35
|
+
if (claims.iss !== (process.env.PLUGIN_SERVICE_ISSUER ?? 'easyai-main'))
|
|
36
|
+
fail('调用凭证签发方无效');
|
|
37
|
+
if (claims.aud !== (process.env.PLUGIN_SERVICE_AUDIENCE ?? 'easyai-plugin-service'))
|
|
38
|
+
fail('调用凭证接收方无效');
|
|
39
|
+
if (!claims.sub || typeof claims.sub !== 'string')
|
|
40
|
+
fail('调用凭证主体无效');
|
|
41
|
+
if (!Number.isSafeInteger(claims.iat) || !Number.isSafeInteger(claims.exp))
|
|
42
|
+
fail('调用凭证时间无效');
|
|
43
|
+
if (claims.exp <= now - 5)
|
|
44
|
+
fail('调用凭证已过期');
|
|
45
|
+
if (claims.iat > now + 30 || claims.exp - claims.iat > 300)
|
|
46
|
+
fail('调用凭证有效期无效');
|
|
47
|
+
if (!Array.isArray(claims.scope) || !claims.scope.every(scope => typeof scope === 'string'))
|
|
48
|
+
fail('调用凭证权限无效');
|
|
49
|
+
return claims;
|
|
50
|
+
}
|
|
51
|
+
export async function authenticate(header, admin = false) {
|
|
52
|
+
const mode = process.env.PLUGIN_AUTH_MODE ?? (process.env.NODE_ENV === 'production' ? 'token' : 'local');
|
|
53
|
+
if (mode === 'local') {
|
|
54
|
+
if (process.env.NODE_ENV === 'production')
|
|
55
|
+
fail('生产环境不能使用本地认证模式');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (mode !== 'token')
|
|
59
|
+
fail('插件服务认证模式无效');
|
|
60
|
+
const match = /^Bearer ([A-Za-z0-9_.-]+)$/.exec(header ?? '');
|
|
61
|
+
if (!match)
|
|
62
|
+
fail('缺少调用凭证');
|
|
63
|
+
const claims = verifyToken(match[1]);
|
|
64
|
+
if (!claims.scope.includes('invoke'))
|
|
65
|
+
fail('调用凭证缺少执行权限');
|
|
66
|
+
if (admin && !claims.scope.includes('admin'))
|
|
67
|
+
fail('调用凭证缺少管理权限');
|
|
68
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { RuntimeStore } from './store.js';
|
|
2
|
+
import type { Plugin } from '../sdk/index.js';
|
|
3
|
+
import { type Catalog, type ExecutionRequest, type PluginRuntimeContext } from '../protocol/index.js';
|
|
4
|
+
export declare class Runtime {
|
|
5
|
+
private readonly presentations;
|
|
6
|
+
private readonly executionEntries;
|
|
7
|
+
readonly store: RuntimeStore;
|
|
8
|
+
readonly plugins: Plugin[];
|
|
9
|
+
private queue;
|
|
10
|
+
private active;
|
|
11
|
+
/** Task grants stay in memory only. They are deliberately excluded from SQLite records and snapshots. */
|
|
12
|
+
private hostFiles;
|
|
13
|
+
private draining;
|
|
14
|
+
private readonly releaseInstanceLock;
|
|
15
|
+
constructor(path: string, plugins?: Plugin[], presentations?: Record<string, Record<string, unknown>>, executionEntries?: Record<string, {
|
|
16
|
+
entry: string;
|
|
17
|
+
digest: string;
|
|
18
|
+
entryHash: string;
|
|
19
|
+
}>);
|
|
20
|
+
plugin(id: string): Plugin;
|
|
21
|
+
configure(id: string, patch: {
|
|
22
|
+
enabled?: boolean;
|
|
23
|
+
config?: Record<string, unknown>;
|
|
24
|
+
expectedVersion?: number;
|
|
25
|
+
}): import("../protocol/index.js").CatalogEntry;
|
|
26
|
+
catalog(): Catalog;
|
|
27
|
+
checkConfig(id: string): Promise<{
|
|
28
|
+
supported: boolean;
|
|
29
|
+
validation: string;
|
|
30
|
+
message: string;
|
|
31
|
+
result?: undefined;
|
|
32
|
+
} | {
|
|
33
|
+
supported: boolean;
|
|
34
|
+
validation: string;
|
|
35
|
+
result: unknown;
|
|
36
|
+
message?: undefined;
|
|
37
|
+
}>;
|
|
38
|
+
query(id: string, name: string, input: Record<string, unknown>, context: PluginRuntimeContext, signal?: AbortSignal): Promise<unknown>;
|
|
39
|
+
normalize(id: string, nodeType: string, parameters: Record<string, unknown>): Record<string, unknown>;
|
|
40
|
+
submit(request: ExecutionRequest): {
|
|
41
|
+
created: boolean;
|
|
42
|
+
record: import("../protocol/index.js").ExecutionRecord;
|
|
43
|
+
};
|
|
44
|
+
private enqueue;
|
|
45
|
+
cancel(id: string): import("../protocol/index.js").ExecutionRecord;
|
|
46
|
+
private pump;
|
|
47
|
+
private killWorker;
|
|
48
|
+
close(): Promise<void>;
|
|
49
|
+
}
|