@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.
@@ -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[];
@@ -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.restartRequired
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 (!ctx.patchConfig) {
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
- const result = await ctx.patchConfig({
332
+ result = await patch.call(ctx, {
239
333
  ...(instanceKey === undefined ? {} : { instanceKey }),
240
334
  expectedRevision,
241
335
  patches,
242
336
  });
243
- if (result.status === 'saved') {
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
- emit({ requestId, data: result });
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.restartRequired
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
- // These handlers require an authenticated principal and verify its bound personal owner.
842
- if ((m === 'GET' || m === 'PUT') && (pathname === `${normalizedBase}/agent/personal-models`
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 === 'GET' && pathname === `${normalizedBase}/agent/personal-sessions`)
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.writeHead(503);
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.12",
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.12",
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.18"
24
+ "@zhin.js/plugin-runtime": "1.1.20"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
@@ -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.restartRequired
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 (!ctx.patchConfig) {
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
- const result = await ctx.patchConfig({
562
+ result = await patch.call(ctx, {
487
563
  ...(instanceKey === undefined ? {} : { instanceKey }),
488
564
  expectedRevision,
489
565
  patches,
490
566
  });
491
- if (result.status === 'saved') {
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.restartRequired
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
- // These handlers require an authenticated principal and verify its bound personal owner.
1060
- if ((m === 'GET' || m === 'PUT') && (pathname === `${normalizedBase}/agent/personal-models`
1061
- || pathname === `${normalizedBase}/agent/personal-session`)) return true;
1062
- if (m === 'GET' && pathname === `${normalizedBase}/agent/personal-sessions`) return true;
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.writeHead(503);
372
- response.end();
373
+ writeJson(response, 503, { success: false, code: 'unavailable', requestOutcome: 'not-dispatched' });
373
374
  return;
374
375
  }
375
376
  if (!release) {