@zhin.js/host-http 1.1.12 → 1.1.14
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/lib/console-rpc.d.ts +8 -3
- package/lib/console-rpc.js +142 -21
- package/lib/http-host.js +3 -2
- package/package.json +3 -3
- package/src/console-rpc.ts +117 -22
- package/src/http-host.ts +3 -2
package/lib/console-rpc.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AuthScope } from './token-registry.js';
|
|
2
2
|
import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
|
|
3
|
-
import { type ConsoleConfigPatchRequest, type ConsoleConfigPatchResult, type ConsoleConfigSource, type ConsoleConfigExplanation, type ConsoleEndpointSummary } from '@zhin.js/console-protocol';
|
|
3
|
+
import { type ConsoleConfigPatchRequest, type ConsoleConfigPatchResult, type ConsoleConfigProjectPatchResult, type ConsoleConfigSource, type ConsoleConfigExplanation, type ConsoleEndpointSummary } from '@zhin.js/console-protocol';
|
|
4
4
|
import { type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
|
|
5
5
|
/**
|
|
6
6
|
* Console RPC 请求结构:未锚定 endpoint 的会话地址(结构对齐
|
|
@@ -155,19 +155,24 @@ export type RuntimeConsoleRpcContext = {
|
|
|
155
155
|
replaceConfigSource?(source: string, expectedRevision: string): Promise<{
|
|
156
156
|
readonly revision: string;
|
|
157
157
|
readonly restartRequired: boolean;
|
|
158
|
-
}>;
|
|
158
|
+
} & import('@zhin.js/console-protocol').ConsoleConfigApplicationStatus>;
|
|
159
159
|
/**
|
|
160
160
|
* Full-scope write: set `document[pluginName] = data` and persist.
|
|
161
161
|
* Returns whether a process restart is required.
|
|
162
162
|
*/
|
|
163
163
|
setConfigKey?(pluginName: string, data: unknown): Promise<{
|
|
164
164
|
restartRequired: boolean;
|
|
165
|
-
}>;
|
|
165
|
+
} & import('@zhin.js/console-protocol').ConsoleConfigApplicationStatus>;
|
|
166
166
|
/**
|
|
167
167
|
* Full-scope, path-allowlisted and revision-checked write (`config:patch`).
|
|
168
168
|
* Scope and schema checks belong to the implementation; scope violations throw.
|
|
169
169
|
*/
|
|
170
170
|
patchConfig?(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
|
|
171
|
+
/** Full-scope, revision-checked project administration, distinct from page allowlists. */
|
|
172
|
+
patchProjectConfig?(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>): Promise<ConsoleConfigProjectPatchResult>;
|
|
173
|
+
/** Full-scope project read (`config:get-project`); masked unless `reveal`. */
|
|
174
|
+
readProjectConfig?(request: import('@zhin.js/console-protocol').ConsoleConfigProjectReadRequest): Promise<import('@zhin.js/console-protocol').ConsoleConfigProjectRead>;
|
|
175
|
+
applyConfig?(expectedRevision: string): Promise<import('@zhin.js/console-protocol').ConsoleConfigApplyResult>;
|
|
171
176
|
/** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
|
|
172
177
|
setPluginEnabled?(instanceKey: string, enabled: boolean): Promise<Readonly<{
|
|
173
178
|
disabled: readonly string[];
|
package/lib/console-rpc.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONFIG_RPC, ENDPOINT_RPC, assertDemoConsoleRpcAllowed, } from '@zhin.js/console-protocol';
|
|
1
|
+
import { CONFIG_PATCH_LIMITS, CONFIG_RPC, ENDPOINT_RPC, assertDemoConsoleRpcAllowed, } from '@zhin.js/console-protocol';
|
|
2
2
|
import { dispatchExtendedConsoleRpc } from './console-rpc-extended/index.js';
|
|
3
3
|
import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
|
|
4
4
|
/** Canonical Console wire fields → runtime conversation address. */
|
|
@@ -21,6 +21,35 @@ export function pickRpcReply(message, payloads) {
|
|
|
21
21
|
?? payloads[payloads.length - 1]
|
|
22
22
|
?? null);
|
|
23
23
|
}
|
|
24
|
+
const FORBIDDEN_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
25
|
+
/**
|
|
26
|
+
* Structural check for `config:patch-project` operations at the RPC boundary, so no project
|
|
27
|
+
* port implementation receives a malformed request. The store repeats the full validation.
|
|
28
|
+
*/
|
|
29
|
+
function invalidProjectPatchOperations(patches) {
|
|
30
|
+
if (patches.length === 0)
|
|
31
|
+
return 'patches 必须是非空数组';
|
|
32
|
+
if (patches.length > CONFIG_PATCH_LIMITS.maxOperations)
|
|
33
|
+
return `一次最多 ${CONFIG_PATCH_LIMITS.maxOperations} 个 patch`;
|
|
34
|
+
for (const [index, item] of patches.entries()) {
|
|
35
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
36
|
+
return `patches[${index}] 必须是对象`;
|
|
37
|
+
const operation = item;
|
|
38
|
+
const path = operation.path;
|
|
39
|
+
if (!Array.isArray(path) || path.length === 0 || path.length > CONFIG_PATCH_LIMITS.maxPathDepth
|
|
40
|
+
|| path.some(segment => typeof segment !== 'string' || !segment
|
|
41
|
+
|| segment.length > CONFIG_PATCH_LIMITS.maxSegmentLength || FORBIDDEN_PATCH_SEGMENTS.has(segment))) {
|
|
42
|
+
return `patches[${index}].path 必须是 1–${CONFIG_PATCH_LIMITS.maxPathDepth} 段的有效字符串数组`;
|
|
43
|
+
}
|
|
44
|
+
if (operation.op === 'set') {
|
|
45
|
+
if (!Object.hasOwn(operation, 'value') || operation.value === undefined)
|
|
46
|
+
return `patches[${index}] 的 set 需要 value`;
|
|
47
|
+
}
|
|
48
|
+
else if (operation.op !== 'remove')
|
|
49
|
+
return `patches[${index}].op 只能是 set 或 remove`;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
24
53
|
export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
25
54
|
const payloads = [];
|
|
26
55
|
const emit = (payload) => {
|
|
@@ -175,6 +204,30 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
175
204
|
}
|
|
176
205
|
return payloads;
|
|
177
206
|
}
|
|
207
|
+
case CONFIG_RPC.APPLY: {
|
|
208
|
+
if (ctx.authScope !== 'full') {
|
|
209
|
+
emit({ requestId, error: 'Full Console scope is required' });
|
|
210
|
+
return payloads;
|
|
211
|
+
}
|
|
212
|
+
if (typeof message.expectedRevision !== 'string' || !/^[a-f0-9]{64}$/u.test(message.expectedRevision)) {
|
|
213
|
+
emit({ requestId, error: 'expectedRevision is required' });
|
|
214
|
+
return payloads;
|
|
215
|
+
}
|
|
216
|
+
if (!ctx.applyConfig) {
|
|
217
|
+
emit({ requestId, error: 'Config application is not configured' });
|
|
218
|
+
return payloads;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const result = await ctx.applyConfig(message.expectedRevision);
|
|
222
|
+
emit({ requestId, data: result });
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Source reads can fail before an operation is admitted. Do not echo a
|
|
226
|
+
// YAML parser's source excerpt or turn unavailable evidence into success.
|
|
227
|
+
emit({ requestId, error: 'Configuration application is unavailable; read the source and runtime status before retrying' });
|
|
228
|
+
}
|
|
229
|
+
return payloads;
|
|
230
|
+
}
|
|
178
231
|
case CONFIG_RPC.REPLACE_SOURCE: {
|
|
179
232
|
try {
|
|
180
233
|
const source = message.source;
|
|
@@ -198,9 +251,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
198
251
|
data: {
|
|
199
252
|
success: true,
|
|
200
253
|
...result,
|
|
201
|
-
message: result
|
|
202
|
-
? '配置已保存,需重启进程才能生效'
|
|
203
|
-
: '配置已保存',
|
|
254
|
+
message: configurationSaveMessage(result),
|
|
204
255
|
},
|
|
205
256
|
});
|
|
206
257
|
}
|
|
@@ -212,12 +263,46 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
212
263
|
}
|
|
213
264
|
return payloads;
|
|
214
265
|
}
|
|
266
|
+
case CONFIG_RPC.GET_PROJECT: {
|
|
267
|
+
if (ctx.authScope !== 'full') {
|
|
268
|
+
emit({ requestId, error: 'Full scope required' });
|
|
269
|
+
return payloads;
|
|
270
|
+
}
|
|
271
|
+
const { path, reveal } = message;
|
|
272
|
+
if (path !== undefined && (!Array.isArray(path) || path.length > CONFIG_PATCH_LIMITS.maxPathDepth
|
|
273
|
+
|| path.some(segment => typeof segment !== 'string' || !segment || segment.length > CONFIG_PATCH_LIMITS.maxSegmentLength))) {
|
|
274
|
+
emit({ requestId, error: 'path 必须是非空字符串数组' });
|
|
275
|
+
return payloads;
|
|
276
|
+
}
|
|
277
|
+
if (reveal !== undefined && typeof reveal !== 'boolean') {
|
|
278
|
+
emit({ requestId, error: 'reveal 必须是布尔值' });
|
|
279
|
+
return payloads;
|
|
280
|
+
}
|
|
281
|
+
if (!ctx.readProjectConfig) {
|
|
282
|
+
emit({ requestId, error: 'Config project read is not configured' });
|
|
283
|
+
return payloads;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
emit({ requestId, data: await ctx.readProjectConfig({ path: (path ?? []), reveal: reveal === true }) });
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// Parser/IO messages can quote document source (and therefore secrets): never echo them.
|
|
290
|
+
emit({ requestId, error: 'Configuration read is unavailable; read the project file locally to diagnose it' });
|
|
291
|
+
}
|
|
292
|
+
return payloads;
|
|
293
|
+
}
|
|
294
|
+
case CONFIG_RPC.PATCH_PROJECT:
|
|
215
295
|
case CONFIG_RPC.PATCH: {
|
|
216
296
|
if (ctx.authScope !== 'full') {
|
|
217
297
|
emit({ requestId, error: 'Full scope required' });
|
|
218
298
|
return payloads;
|
|
219
299
|
}
|
|
300
|
+
const projectPatch = message.type === CONFIG_RPC.PATCH_PROJECT;
|
|
220
301
|
const { instanceKey, expectedRevision, patches } = message;
|
|
302
|
+
if (projectPatch && instanceKey !== undefined) {
|
|
303
|
+
emit({ requestId, error: 'Project patches must not specify instanceKey' });
|
|
304
|
+
return payloads;
|
|
305
|
+
}
|
|
221
306
|
if (instanceKey !== undefined && (typeof instanceKey !== 'string' || !instanceKey || instanceKey.length > 128)) {
|
|
222
307
|
emit({ requestId, error: 'instanceKey 必须是插件实例名,或省略以使用 Host 白名单' });
|
|
223
308
|
return payloads;
|
|
@@ -230,17 +315,36 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
230
315
|
emit({ requestId, error: 'patches 必须是数组' });
|
|
231
316
|
return payloads;
|
|
232
317
|
}
|
|
233
|
-
if (
|
|
318
|
+
if (projectPatch) {
|
|
319
|
+
const invalid = invalidProjectPatchOperations(patches);
|
|
320
|
+
if (invalid) {
|
|
321
|
+
emit({ requestId, error: invalid });
|
|
322
|
+
return payloads;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const patch = projectPatch ? ctx.patchProjectConfig : ctx.patchConfig;
|
|
326
|
+
if (!patch) {
|
|
234
327
|
emit({ requestId, error: 'Config patch is not configured' });
|
|
235
328
|
return payloads;
|
|
236
329
|
}
|
|
330
|
+
let result;
|
|
237
331
|
try {
|
|
238
|
-
|
|
332
|
+
result = await patch.call(ctx, {
|
|
239
333
|
...(instanceKey === undefined ? {} : { instanceKey }),
|
|
240
334
|
expectedRevision,
|
|
241
335
|
patches,
|
|
242
336
|
});
|
|
243
|
-
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
emit({
|
|
340
|
+
requestId,
|
|
341
|
+
error: `Failed to patch config: ${error instanceof Error ? error.message : String(error)}`,
|
|
342
|
+
});
|
|
343
|
+
return payloads;
|
|
344
|
+
}
|
|
345
|
+
if (result.status === 'saved') {
|
|
346
|
+
// The write has committed: a notification failure must not turn the receipt into a rejection.
|
|
347
|
+
try {
|
|
244
348
|
ctx.publishEvent?.('config:updated', {
|
|
245
349
|
pluginName: instanceKey ?? null,
|
|
246
350
|
keys: [...new Set(result.paths.map(path => path[0]))],
|
|
@@ -248,14 +352,9 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
248
352
|
revision: result.revision,
|
|
249
353
|
});
|
|
250
354
|
}
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
catch (error) {
|
|
254
|
-
emit({
|
|
255
|
-
requestId,
|
|
256
|
-
error: `Failed to patch config: ${error instanceof Error ? error.message : String(error)}`,
|
|
257
|
-
});
|
|
355
|
+
catch { /* subscribers resynchronise from the next read */ }
|
|
258
356
|
}
|
|
357
|
+
emit({ requestId, data: result });
|
|
259
358
|
return payloads;
|
|
260
359
|
}
|
|
261
360
|
case CONFIG_RPC.SET: {
|
|
@@ -291,10 +390,9 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
291
390
|
requestId,
|
|
292
391
|
data: {
|
|
293
392
|
success: true,
|
|
393
|
+
...result,
|
|
294
394
|
reloaded: false,
|
|
295
|
-
message: result
|
|
296
|
-
? '配置已保存,需重启进程才能生效'
|
|
297
|
-
: '配置已保存',
|
|
395
|
+
message: configurationSaveMessage(result),
|
|
298
396
|
},
|
|
299
397
|
});
|
|
300
398
|
}
|
|
@@ -838,11 +936,12 @@ function errorReply(payloads, requestId, error) {
|
|
|
838
936
|
export function isDemoHttpAllowed(method, pathname, apiBase) {
|
|
839
937
|
const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
|
|
840
938
|
const m = method.toUpperCase();
|
|
841
|
-
//
|
|
842
|
-
if (
|
|
843
|
-
|| pathname === `${normalizedBase}/agent/personal-session`))
|
|
939
|
+
// Each inference handler resolves a static authenticated personal binding before reading or mutating anything.
|
|
940
|
+
if (m === 'GET' && ['', '/owners', '/export', '/mutation', '/sessions', '/session'].some(path => pathname === `${normalizedBase}/agent/inference${path}`))
|
|
844
941
|
return true;
|
|
845
|
-
if (m === '
|
|
942
|
+
if (m === 'PUT' && pathname === `${normalizedBase}/agent/inference/session`)
|
|
943
|
+
return true;
|
|
944
|
+
if (m === 'POST' && ['/plan', '/apply', '/key/prepare', '/discover', '/profile-schema'].some(path => pathname === `${normalizedBase}/agent/inference${path}`))
|
|
846
945
|
return true;
|
|
847
946
|
if (m === 'GET' && pathname === `${normalizedBase}/events`)
|
|
848
947
|
return true;
|
|
@@ -862,6 +961,28 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
|
|
|
862
961
|
return true;
|
|
863
962
|
return false;
|
|
864
963
|
}
|
|
964
|
+
function configurationSaveMessage(result) {
|
|
965
|
+
if (result.applicationError === 'superseded')
|
|
966
|
+
return '配置已保存,但该次生效申请已被替代';
|
|
967
|
+
if (result.applicationError === 'invalid')
|
|
968
|
+
return '配置已保存,但配置校验失败,未生效';
|
|
969
|
+
if (result.applicationError)
|
|
970
|
+
return '配置已保存,生效结果未确认';
|
|
971
|
+
if (result.application) {
|
|
972
|
+
if (result.revision && result.application.sourceRevision !== result.revision)
|
|
973
|
+
return '配置已保存,但已被较新修订替代';
|
|
974
|
+
if (result.application.groups.some(group => group.status === 'unknown'))
|
|
975
|
+
return '配置已保存,生效结果未确认';
|
|
976
|
+
if (result.application.groups.some(group => group.status === 'superseded'))
|
|
977
|
+
return '配置已保存,但该次生效申请已被替代';
|
|
978
|
+
if (result.application.groups.some(group => ['failed', 'blocked'].includes(group.status)))
|
|
979
|
+
return '配置已保存,部分配置未生效';
|
|
980
|
+
if (result.restartRequired)
|
|
981
|
+
return '配置已保存,部分配置待重启生效';
|
|
982
|
+
return '配置已保存并已生效';
|
|
983
|
+
}
|
|
984
|
+
return result.restartRequired ? '配置已保存,需重启进程才能生效' : '配置已保存';
|
|
985
|
+
}
|
|
865
986
|
const DEMO_INTROSPECTION_PATHS = new Set([
|
|
866
987
|
'/introspection/commands',
|
|
867
988
|
'/introspection/middlewares',
|
package/lib/http-host.js
CHANGED
|
@@ -212,6 +212,8 @@ export function createHttpHost(options = {}) {
|
|
|
212
212
|
applyCors(response, origin, corsOrigins);
|
|
213
213
|
writeJson(response, auth.forbidden ? 403 : 401, {
|
|
214
214
|
success: false,
|
|
215
|
+
code: auth.forbidden ? 'forbidden' : 'unauthorized',
|
|
216
|
+
requestOutcome: 'not-dispatched',
|
|
215
217
|
error: auth.forbidden ? 'Token scope does not allow this route' : 'Invalid or missing token',
|
|
216
218
|
});
|
|
217
219
|
return;
|
|
@@ -231,8 +233,7 @@ export function createHttpHost(options = {}) {
|
|
|
231
233
|
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
232
234
|
const release = route.admission?.acquire();
|
|
233
235
|
if (route.admission && !release) {
|
|
234
|
-
response
|
|
235
|
-
response.end();
|
|
236
|
+
writeJson(response, 503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' });
|
|
236
237
|
return;
|
|
237
238
|
}
|
|
238
239
|
if (!release) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.14",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"ws": "^8.21.1",
|
|
20
|
-
"@zhin.js/console-protocol": "1.1.
|
|
20
|
+
"@zhin.js/console-protocol": "1.1.13",
|
|
21
21
|
"@zhin.js/host-http-contract": "1.1.1",
|
|
22
22
|
"@zhin.js/logger": "1.1.2",
|
|
23
23
|
"@zhin.js/schedule": "1.1.3",
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.20"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|
package/src/console-rpc.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { AuthScope } from './token-registry.js';
|
|
2
2
|
import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
|
|
3
3
|
import {
|
|
4
|
+
CONFIG_PATCH_LIMITS,
|
|
4
5
|
CONFIG_RPC,
|
|
5
6
|
ENDPOINT_RPC,
|
|
6
7
|
type ConsoleConfigPatchRequest,
|
|
7
8
|
type ConsoleConfigPatchResult,
|
|
9
|
+
type ConsoleConfigProjectPatchResult,
|
|
8
10
|
type ConsoleConfigSource,
|
|
9
11
|
type ConsoleConfigExplanation,
|
|
10
12
|
type ConsoleEndpointSummary,
|
|
@@ -192,17 +194,22 @@ export type RuntimeConsoleRpcContext = {
|
|
|
192
194
|
replaceConfigSource?(
|
|
193
195
|
source: string,
|
|
194
196
|
expectedRevision: string,
|
|
195
|
-
): Promise<{ readonly revision: string; readonly restartRequired: boolean }>;
|
|
197
|
+
): Promise<{ readonly revision: string; readonly restartRequired: boolean } & import('@zhin.js/console-protocol').ConsoleConfigApplicationStatus>;
|
|
196
198
|
/**
|
|
197
199
|
* Full-scope write: set `document[pluginName] = data` and persist.
|
|
198
200
|
* Returns whether a process restart is required.
|
|
199
201
|
*/
|
|
200
|
-
setConfigKey?(pluginName: string, data: unknown): Promise<{ restartRequired: boolean }>;
|
|
202
|
+
setConfigKey?(pluginName: string, data: unknown): Promise<{ restartRequired: boolean } & import('@zhin.js/console-protocol').ConsoleConfigApplicationStatus>;
|
|
201
203
|
/**
|
|
202
204
|
* Full-scope, path-allowlisted and revision-checked write (`config:patch`).
|
|
203
205
|
* Scope and schema checks belong to the implementation; scope violations throw.
|
|
204
206
|
*/
|
|
205
207
|
patchConfig?(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
|
|
208
|
+
/** Full-scope, revision-checked project administration, distinct from page allowlists. */
|
|
209
|
+
patchProjectConfig?(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>): Promise<ConsoleConfigProjectPatchResult>;
|
|
210
|
+
/** Full-scope project read (`config:get-project`); masked unless `reveal`. */
|
|
211
|
+
readProjectConfig?(request: import('@zhin.js/console-protocol').ConsoleConfigProjectReadRequest): Promise<import('@zhin.js/console-protocol').ConsoleConfigProjectRead>;
|
|
212
|
+
applyConfig?(expectedRevision: string): Promise<import('@zhin.js/console-protocol').ConsoleConfigApplyResult>;
|
|
206
213
|
/** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
|
|
207
214
|
setPluginEnabled?(
|
|
208
215
|
instanceKey: string,
|
|
@@ -278,6 +285,31 @@ export function pickRpcReply(
|
|
|
278
285
|
);
|
|
279
286
|
}
|
|
280
287
|
|
|
288
|
+
const FORBIDDEN_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Structural check for `config:patch-project` operations at the RPC boundary, so no project
|
|
292
|
+
* port implementation receives a malformed request. The store repeats the full validation.
|
|
293
|
+
*/
|
|
294
|
+
function invalidProjectPatchOperations(patches: readonly unknown[]): string | undefined {
|
|
295
|
+
if (patches.length === 0) return 'patches 必须是非空数组';
|
|
296
|
+
if (patches.length > CONFIG_PATCH_LIMITS.maxOperations) return `一次最多 ${CONFIG_PATCH_LIMITS.maxOperations} 个 patch`;
|
|
297
|
+
for (const [index, item] of patches.entries()) {
|
|
298
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return `patches[${index}] 必须是对象`;
|
|
299
|
+
const operation = item as Record<string, unknown>;
|
|
300
|
+
const path = operation.path;
|
|
301
|
+
if (!Array.isArray(path) || path.length === 0 || path.length > CONFIG_PATCH_LIMITS.maxPathDepth
|
|
302
|
+
|| path.some(segment => typeof segment !== 'string' || !segment
|
|
303
|
+
|| segment.length > CONFIG_PATCH_LIMITS.maxSegmentLength || FORBIDDEN_PATCH_SEGMENTS.has(segment))) {
|
|
304
|
+
return `patches[${index}].path 必须是 1–${CONFIG_PATCH_LIMITS.maxPathDepth} 段的有效字符串数组`;
|
|
305
|
+
}
|
|
306
|
+
if (operation.op === 'set') {
|
|
307
|
+
if (!Object.hasOwn(operation, 'value') || operation.value === undefined) return `patches[${index}] 的 set 需要 value`;
|
|
308
|
+
} else if (operation.op !== 'remove') return `patches[${index}].op 只能是 set 或 remove`;
|
|
309
|
+
}
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
281
313
|
export async function dispatchRuntimeConsoleRpc(
|
|
282
314
|
message: RuntimeConsoleRpcMessage,
|
|
283
315
|
ctx: RuntimeConsoleRpcContext,
|
|
@@ -427,6 +459,22 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
427
459
|
}
|
|
428
460
|
return payloads;
|
|
429
461
|
}
|
|
462
|
+
case CONFIG_RPC.APPLY: {
|
|
463
|
+
if (ctx.authScope !== 'full') { emit({ requestId, error: 'Full Console scope is required' }); return payloads; }
|
|
464
|
+
if (typeof message.expectedRevision !== 'string' || !/^[a-f0-9]{64}$/u.test(message.expectedRevision)) {
|
|
465
|
+
emit({ requestId, error: 'expectedRevision is required' }); return payloads;
|
|
466
|
+
}
|
|
467
|
+
if (!ctx.applyConfig) { emit({ requestId, error: 'Config application is not configured' }); return payloads; }
|
|
468
|
+
try {
|
|
469
|
+
const result = await ctx.applyConfig(message.expectedRevision);
|
|
470
|
+
emit({ requestId, data: result });
|
|
471
|
+
} catch {
|
|
472
|
+
// Source reads can fail before an operation is admitted. Do not echo a
|
|
473
|
+
// YAML parser's source excerpt or turn unavailable evidence into success.
|
|
474
|
+
emit({ requestId, error: 'Configuration application is unavailable; read the source and runtime status before retrying' });
|
|
475
|
+
}
|
|
476
|
+
return payloads;
|
|
477
|
+
}
|
|
430
478
|
case CONFIG_RPC.REPLACE_SOURCE: {
|
|
431
479
|
try {
|
|
432
480
|
const source = message.source;
|
|
@@ -450,9 +498,7 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
450
498
|
data: {
|
|
451
499
|
success: true,
|
|
452
500
|
...result,
|
|
453
|
-
message: result
|
|
454
|
-
? '配置已保存,需重启进程才能生效'
|
|
455
|
-
: '配置已保存',
|
|
501
|
+
message: configurationSaveMessage(result),
|
|
456
502
|
},
|
|
457
503
|
});
|
|
458
504
|
} catch (error) {
|
|
@@ -463,9 +509,33 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
463
509
|
}
|
|
464
510
|
return payloads;
|
|
465
511
|
}
|
|
512
|
+
case CONFIG_RPC.GET_PROJECT: {
|
|
513
|
+
if (ctx.authScope !== 'full') { emit({ requestId, error: 'Full scope required' }); return payloads; }
|
|
514
|
+
const { path, reveal } = message;
|
|
515
|
+
if (path !== undefined && (!Array.isArray(path) || path.length > CONFIG_PATCH_LIMITS.maxPathDepth
|
|
516
|
+
|| path.some(segment => typeof segment !== 'string' || !segment || segment.length > CONFIG_PATCH_LIMITS.maxSegmentLength))) {
|
|
517
|
+
emit({ requestId, error: 'path 必须是非空字符串数组' });
|
|
518
|
+
return payloads;
|
|
519
|
+
}
|
|
520
|
+
if (reveal !== undefined && typeof reveal !== 'boolean') { emit({ requestId, error: 'reveal 必须是布尔值' }); return payloads; }
|
|
521
|
+
if (!ctx.readProjectConfig) { emit({ requestId, error: 'Config project read is not configured' }); return payloads; }
|
|
522
|
+
try {
|
|
523
|
+
emit({ requestId, data: await ctx.readProjectConfig({ path: (path ?? []) as string[], reveal: reveal === true }) });
|
|
524
|
+
} catch {
|
|
525
|
+
// Parser/IO messages can quote document source (and therefore secrets): never echo them.
|
|
526
|
+
emit({ requestId, error: 'Configuration read is unavailable; read the project file locally to diagnose it' });
|
|
527
|
+
}
|
|
528
|
+
return payloads;
|
|
529
|
+
}
|
|
530
|
+
case CONFIG_RPC.PATCH_PROJECT:
|
|
466
531
|
case CONFIG_RPC.PATCH: {
|
|
467
532
|
if (ctx.authScope !== 'full') { emit({ requestId, error: 'Full scope required' }); return payloads; }
|
|
533
|
+
const projectPatch = message.type === CONFIG_RPC.PATCH_PROJECT;
|
|
468
534
|
const { instanceKey, expectedRevision, patches } = message;
|
|
535
|
+
if (projectPatch && instanceKey !== undefined) {
|
|
536
|
+
emit({ requestId, error: 'Project patches must not specify instanceKey' });
|
|
537
|
+
return payloads;
|
|
538
|
+
}
|
|
469
539
|
if (instanceKey !== undefined && (typeof instanceKey !== 'string' || !instanceKey || instanceKey.length > 128)) {
|
|
470
540
|
emit({ requestId, error: 'instanceKey 必须是插件实例名,或省略以使用 Host 白名单' });
|
|
471
541
|
return payloads;
|
|
@@ -478,31 +548,41 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
478
548
|
emit({ requestId, error: 'patches 必须是数组' });
|
|
479
549
|
return payloads;
|
|
480
550
|
}
|
|
481
|
-
if (
|
|
551
|
+
if (projectPatch) {
|
|
552
|
+
const invalid = invalidProjectPatchOperations(patches);
|
|
553
|
+
if (invalid) { emit({ requestId, error: invalid }); return payloads; }
|
|
554
|
+
}
|
|
555
|
+
const patch = projectPatch ? ctx.patchProjectConfig : ctx.patchConfig;
|
|
556
|
+
if (!patch) {
|
|
482
557
|
emit({ requestId, error: 'Config patch is not configured' });
|
|
483
558
|
return payloads;
|
|
484
559
|
}
|
|
560
|
+
let result: Awaited<ReturnType<typeof patch>>;
|
|
485
561
|
try {
|
|
486
|
-
|
|
562
|
+
result = await patch.call(ctx, {
|
|
487
563
|
...(instanceKey === undefined ? {} : { instanceKey }),
|
|
488
564
|
expectedRevision,
|
|
489
565
|
patches,
|
|
490
566
|
});
|
|
491
|
-
|
|
567
|
+
} catch (error) {
|
|
568
|
+
emit({
|
|
569
|
+
requestId,
|
|
570
|
+
error: `Failed to patch config: ${error instanceof Error ? error.message : String(error)}`,
|
|
571
|
+
});
|
|
572
|
+
return payloads;
|
|
573
|
+
}
|
|
574
|
+
if (result.status === 'saved') {
|
|
575
|
+
// The write has committed: a notification failure must not turn the receipt into a rejection.
|
|
576
|
+
try {
|
|
492
577
|
ctx.publishEvent?.('config:updated', {
|
|
493
578
|
pluginName: instanceKey ?? null,
|
|
494
579
|
keys: [...new Set(result.paths.map(path => path[0]))],
|
|
495
580
|
paths: result.paths,
|
|
496
581
|
revision: result.revision,
|
|
497
582
|
});
|
|
498
|
-
}
|
|
499
|
-
emit({ requestId, data: result });
|
|
500
|
-
} catch (error) {
|
|
501
|
-
emit({
|
|
502
|
-
requestId,
|
|
503
|
-
error: `Failed to patch config: ${error instanceof Error ? error.message : String(error)}`,
|
|
504
|
-
});
|
|
583
|
+
} catch { /* subscribers resynchronise from the next read */ }
|
|
505
584
|
}
|
|
585
|
+
emit({ requestId, data: result });
|
|
506
586
|
return payloads;
|
|
507
587
|
}
|
|
508
588
|
case CONFIG_RPC.SET: {
|
|
@@ -538,10 +618,9 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
538
618
|
requestId,
|
|
539
619
|
data: {
|
|
540
620
|
success: true,
|
|
621
|
+
...result,
|
|
541
622
|
reloaded: false,
|
|
542
|
-
message: result
|
|
543
|
-
? '配置已保存,需重启进程才能生效'
|
|
544
|
-
: '配置已保存',
|
|
623
|
+
message: configurationSaveMessage(result),
|
|
545
624
|
},
|
|
546
625
|
});
|
|
547
626
|
} catch (error) {
|
|
@@ -1056,10 +1135,10 @@ function errorReply(
|
|
|
1056
1135
|
export function isDemoHttpAllowed(method: string, pathname: string, apiBase: string): boolean {
|
|
1057
1136
|
const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
|
|
1058
1137
|
const m = method.toUpperCase();
|
|
1059
|
-
//
|
|
1060
|
-
if (
|
|
1061
|
-
|
|
1062
|
-
if (m === '
|
|
1138
|
+
// Each inference handler resolves a static authenticated personal binding before reading or mutating anything.
|
|
1139
|
+
if (m === 'GET' && ['', '/owners', '/export', '/mutation', '/sessions', '/session'].some(path => pathname === `${normalizedBase}/agent/inference${path}`)) return true;
|
|
1140
|
+
if (m === 'PUT' && pathname === `${normalizedBase}/agent/inference/session`) return true;
|
|
1141
|
+
if (m === 'POST' && ['/plan', '/apply', '/key/prepare', '/discover', '/profile-schema'].some(path => pathname === `${normalizedBase}/agent/inference${path}`)) return true;
|
|
1063
1142
|
if (m === 'GET' && pathname === `${normalizedBase}/events`) return true;
|
|
1064
1143
|
if (m === 'GET' && pathname === `${normalizedBase}/events/history`) return true;
|
|
1065
1144
|
if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length))) return true;
|
|
@@ -1072,6 +1151,22 @@ export function isDemoHttpAllowed(method: string, pathname: string, apiBase: str
|
|
|
1072
1151
|
return false;
|
|
1073
1152
|
}
|
|
1074
1153
|
|
|
1154
|
+
function configurationSaveMessage(result: { restartRequired: boolean; revision?: string }
|
|
1155
|
+
& import('@zhin.js/console-protocol').ConsoleConfigApplicationStatus): string {
|
|
1156
|
+
if (result.applicationError === 'superseded') return '配置已保存,但该次生效申请已被替代';
|
|
1157
|
+
if (result.applicationError === 'invalid') return '配置已保存,但配置校验失败,未生效';
|
|
1158
|
+
if (result.applicationError) return '配置已保存,生效结果未确认';
|
|
1159
|
+
if (result.application) {
|
|
1160
|
+
if (result.revision && result.application.sourceRevision !== result.revision) return '配置已保存,但已被较新修订替代';
|
|
1161
|
+
if (result.application.groups.some(group => group.status === 'unknown')) return '配置已保存,生效结果未确认';
|
|
1162
|
+
if (result.application.groups.some(group => group.status === 'superseded')) return '配置已保存,但该次生效申请已被替代';
|
|
1163
|
+
if (result.application.groups.some(group => ['failed', 'blocked'].includes(group.status))) return '配置已保存,部分配置未生效';
|
|
1164
|
+
if (result.restartRequired) return '配置已保存,部分配置待重启生效';
|
|
1165
|
+
return '配置已保存并已生效';
|
|
1166
|
+
}
|
|
1167
|
+
return result.restartRequired ? '配置已保存,需重启进程才能生效' : '配置已保存';
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1075
1170
|
const DEMO_INTROSPECTION_PATHS: ReadonlySet<string> = new Set([
|
|
1076
1171
|
'/introspection/commands',
|
|
1077
1172
|
'/introspection/middlewares',
|
package/src/http-host.ts
CHANGED
|
@@ -350,6 +350,8 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
350
350
|
if (corsOk && origin) applyCors(response, origin, corsOrigins);
|
|
351
351
|
writeJson(response, auth.forbidden ? 403 : 401, {
|
|
352
352
|
success: false,
|
|
353
|
+
code: auth.forbidden ? 'forbidden' : 'unauthorized',
|
|
354
|
+
requestOutcome: 'not-dispatched',
|
|
353
355
|
error: auth.forbidden ? 'Token scope does not allow this route' : 'Invalid or missing token',
|
|
354
356
|
});
|
|
355
357
|
return;
|
|
@@ -368,8 +370,7 @@ export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
|
368
370
|
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
369
371
|
const release = route.admission?.acquire();
|
|
370
372
|
if (route.admission && !release) {
|
|
371
|
-
response
|
|
372
|
-
response.end();
|
|
373
|
+
writeJson(response, 503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' });
|
|
373
374
|
return;
|
|
374
375
|
}
|
|
375
376
|
if (!release) {
|