@zhin.js/host-http 1.2.2 → 1.3.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.
Files changed (40) hide show
  1. package/lib/access-control.d.ts +31 -3
  2. package/lib/access-control.js +96 -10
  3. package/lib/console-rest-pages-helpers.js +24 -2
  4. package/lib/console-rest-pages.d.ts +4 -1
  5. package/lib/console-rest-pages.js +49 -23
  6. package/lib/console-rpc-extended/contracts.d.ts +22 -0
  7. package/lib/console-rpc-extended/dispatcher.js +1 -1
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +1 -1
  10. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  11. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  12. package/lib/console-rpc-handlers.d.ts +9 -1
  13. package/lib/console-rpc-handlers.js +97 -10
  14. package/lib/console-rpc-types.d.ts +2 -0
  15. package/lib/console-rpc-types.js +43 -0
  16. package/lib/console-rpc.d.ts +29 -1
  17. package/lib/console-rpc.js +5 -4
  18. package/lib/http-host.js +5 -4
  19. package/lib/index.d.ts +5 -3
  20. package/lib/index.js +3 -1
  21. package/lib/project-files.d.ts +7 -0
  22. package/lib/project-files.js +88 -11
  23. package/lib/unknown-rpc.d.ts +12 -0
  24. package/lib/unknown-rpc.js +33 -0
  25. package/package.json +3 -3
  26. package/src/access-control.ts +132 -11
  27. package/src/console-rest-pages-helpers.ts +27 -3
  28. package/src/console-rest-pages.ts +52 -15
  29. package/src/console-rpc-extended/contracts.ts +18 -0
  30. package/src/console-rpc-extended/dispatcher.ts +1 -1
  31. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  32. package/src/console-rpc-extended/index.ts +1 -0
  33. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  34. package/src/console-rpc-handlers.ts +99 -9
  35. package/src/console-rpc-types.ts +45 -0
  36. package/src/console-rpc.ts +31 -4
  37. package/src/http-host.ts +6 -2
  38. package/src/index.ts +10 -0
  39. package/src/project-files.ts +95 -11
  40. package/src/unknown-rpc.ts +44 -0
@@ -17,6 +17,15 @@ import {
17
17
  isValidNpmPackageName,
18
18
  } from './marketplace-registry.js';
19
19
  import type { ConsoleMessageEventData } from '@zhin.js/console-protocol';
20
+ import {
21
+ informativeKeywords,
22
+ isLocale,
23
+ normalizePluginCategory,
24
+ packageAuthorName,
25
+ pickLocalizedText,
26
+ readPluginMarketplaceMetadata,
27
+ type PluginLocalizedText,
28
+ } from '@zhin.js/plugin-runtime';
20
29
  import {
21
30
  clampPositiveInt,
22
31
  decodePathParam,
@@ -65,7 +74,10 @@ export interface ConsoleAgentIntrospection {
65
74
  commands?(): readonly unknown[];
66
75
  middlewares?(): readonly unknown[];
67
76
  components?(): readonly unknown[];
68
- bindings?(): readonly unknown[];
77
+ /** Agent bindings with the provider / model they actually run on (may read the provider graph). */
78
+ bindings?(): readonly unknown[] | Promise<readonly unknown[]>;
79
+ /** Recent Agent turns from the trace archive, newest first (#1250). */
80
+ recentTurns?(): readonly unknown[] | Promise<readonly unknown[]>;
69
81
  tools?(): readonly unknown[];
70
82
  promptSections?(): readonly unknown[];
71
83
  mcp?(): { rows: readonly unknown[]; note?: string } | readonly unknown[];
@@ -356,6 +368,11 @@ function registerLogsRoutes(
356
368
  }, { summary: 'Cleanup old system logs', tags: ['console', 'logs'] });
357
369
  }
358
370
 
371
+ /** Locale-keyed texts from an untrusted registry entry (reuses the published-manifest reader). */
372
+ function localizedTextOf(value: unknown): PluginLocalizedText | undefined {
373
+ return readPluginMarketplaceMetadata({ metadata: { displayNameI18n: value } }).displayNameI18n;
374
+ }
375
+
359
376
  // ---------------------------------------------------------------------------
360
377
  // marketplace(legacy marketplace-rest-api.ts;plugins.json + npmmirror)
361
378
  // ---------------------------------------------------------------------------
@@ -390,10 +407,17 @@ function registerMarketplaceRoutes(
390
407
  const versions = meta.versions as Record<string, Record<string, unknown>> | undefined;
391
408
  const latestInfo = latest && versions ? versions[latest] : undefined;
392
409
  const time = meta.time as Record<string, string> | undefined;
410
+ // Marketplace fields come from the published manifest (`zhin.metadata`); the author is the
411
+ // package's author / maintainers, never the publishing account (`_npmUser`, a release bot).
412
+ const market = readPluginMarketplaceMetadata(latestInfo?.zhin);
393
413
  return {
394
414
  name: meta.name,
395
415
  version: latest,
396
- description: meta.description || '',
416
+ ...(market.displayName ? { displayName: market.displayName } : {}),
417
+ ...(market.displayNameI18n ? { displayNameI18n: market.displayNameI18n } : {}),
418
+ ...(market.descriptionI18n ? { descriptionI18n: market.descriptionI18n } : {}),
419
+ ...(market.category ? { category: market.category } : {}),
420
+ description: (latestInfo?.description as string | undefined) || meta.description || '',
397
421
  readme: meta.readme || '',
398
422
  license: meta.license || latestInfo?.license || '',
399
423
  homepage: meta.homepage || latestInfo?.homepage || '',
@@ -401,11 +425,8 @@ function registerMarketplaceRoutes(
401
425
  (meta.repository as { url?: string } | undefined)?.url ||
402
426
  (latestInfo?.repository as { url?: string } | undefined)?.url ||
403
427
  '',
404
- author:
405
- typeof meta.author === 'string'
406
- ? meta.author
407
- : (meta.author as { name?: string } | undefined)?.name || '',
408
- keywords: (latestInfo?.keywords as string[]) || [],
428
+ author: packageAuthorName(latestInfo ?? {}) ?? packageAuthorName(meta) ?? '',
429
+ keywords: informativeKeywords(latestInfo?.keywords),
409
430
  engines: latestInfo?.engines || {},
410
431
  peerDependencies: latestInfo?.peerDependencies || {},
411
432
  versions: Object.keys(versions || {}),
@@ -421,32 +442,48 @@ function registerMarketplaceRoutes(
421
442
  100,
422
443
  Math.max(1, parseInt(url.searchParams.get('size') ?? url.searchParams.get('limit') ?? '20', 10) || 20),
423
444
  );
424
- const category = url.searchParams.get('category') ?? undefined;
445
+ const rawCategory = url.searchParams.get('category') || undefined;
446
+ const category = rawCategory === undefined ? undefined : normalizePluginCategory(rawCategory) ?? rawCategory;
425
447
  const official = url.searchParams.get('official') ?? undefined;
448
+ const rawLocale = url.searchParams.get('locale');
449
+ const locale = isLocale(rawLocale) ? rawLocale : undefined;
426
450
  const searchKeyword = q.trim().toLowerCase();
427
451
 
428
452
  try {
429
453
  const allPlugins = (await registry.list()) as readonly Record<string, unknown>[];
430
- let plugins = allPlugins.map((p) => ({
454
+ let plugins = allPlugins.map((p) => {
455
+ const displayNameI18n = localizedTextOf(p.displayNameI18n);
456
+ const descriptionI18n = localizedTextOf(p.descriptionI18n);
457
+ const displayName = typeof p.displayName === 'string' ? p.displayName : '';
458
+ const description = typeof p.description === 'string' ? p.description : '';
459
+ return {
431
460
  name: p.name,
432
- displayName: p.displayName || '',
461
+ // `locale` resolves the display texts for the caller; translations stay available either way.
462
+ displayName: locale ? pickLocalizedText(displayNameI18n, locale, displayName) ?? '' : displayName,
463
+ ...(displayNameI18n ? { displayNameI18n } : {}),
433
464
  version: p.version || '',
434
- description: p.description || '',
465
+ description: locale ? pickLocalizedText(descriptionI18n, locale, description) ?? '' : description,
466
+ ...(descriptionI18n ? { descriptionI18n } : {}),
435
467
  author: p.author || '',
436
468
  isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
437
469
  official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
438
- category: p.category || 'util',
439
- keywords: (p.tags as string[]) || [],
470
+ category: normalizePluginCategory(p.category) ?? 'utility',
471
+ keywords: informativeKeywords(p.tags),
440
472
  npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
441
473
  date: p.lastUpdate || '',
442
474
  downloads: p.downloads || { weekly: 0, monthly: 0 },
443
475
  readme: p.readme || '',
444
476
  license: p.license || '',
445
- }));
477
+ };
478
+ });
446
479
 
447
480
  if (searchKeyword) {
448
481
  plugins = plugins.filter((p) => {
449
- const haystack = [p.name, p.displayName, p.description, ...(p.keywords || [])]
482
+ const haystack = [
483
+ p.name, p.displayName, p.description,
484
+ ...Object.values(p.displayNameI18n ?? {}), ...Object.values(p.descriptionI18n ?? {}),
485
+ ...p.keywords,
486
+ ]
450
487
  .join(' ')
451
488
  .toLowerCase();
452
489
  return haystack.includes(searchKeyword);
@@ -22,6 +22,11 @@ export interface ConsoleRpcExtendedCtx {
22
22
  databaseHost?: { models: { get(name: string): unknown } };
23
23
  /** Narrow generation-bound persistent-job port; null when Assistant is disabled. */
24
24
  resolveScheduleEngine?: () => ConsoleScheduleEngine | null | undefined;
25
+ /**
26
+ * Resolves a persistent job's delivery target (#1250): `{ resolved, display?, reason? }`, where
27
+ * `resolved: false` means the job's results cannot be delivered (no target / unknown account).
28
+ */
29
+ describeScheduleTarget?: (notify: unknown) => ConsoleScheduleTarget;
25
30
  /** LoginAssist list/submit/cancel(刷新后仍可消费扫码/滑块待办);ADR 0058 flow methods are optional. */
26
31
  loginAssist?: ConsoleLoginAssistPort;
27
32
  /** ADR 0058: the Endpoint's login declaration and lease-scoped login control (`null` = unknown Endpoint). */
@@ -160,6 +165,11 @@ export interface ScheduleJobRow {
160
165
  id: string;
161
166
  cron: string;
162
167
  description?: string;
168
+ /** Host display metadata (`ScheduleJobDisplay` in `@zhin.js/plugin-runtime`). */
169
+ title?: string;
170
+ titleI18n?: Readonly<Partial<Record<string, string>>>;
171
+ kind?: 'plugin-internal' | 'user-task';
172
+ lastRun?: { startedAt: number; finishedAt: number; status: 'succeeded' | 'failed'; error?: string };
163
173
  }
164
174
 
165
175
  export interface InboxSelection {
@@ -219,3 +229,11 @@ export interface EndpointManagementPort {
219
229
  setGroupAdmin?(request: EndpointAdminCommand): Promise<void>;
220
230
  deleteFriend?(request: EndpointUserCommand): Promise<void>;
221
231
  }
232
+
233
+ /** `target` of a `schedule:list` persistent row (#1250). */
234
+ export interface ConsoleScheduleTarget {
235
+ readonly resolved: boolean;
236
+ readonly display?: string;
237
+ /** `missing_target` | `incomplete_target` | `endpoint_not_found` */
238
+ readonly reason?: string;
239
+ }
@@ -21,7 +21,7 @@ export async function dispatchExtendedConsoleRpc(
21
21
  switch (type) {
22
22
  case 'schedule:list':
23
23
  case 'cron:list':
24
- return listSchedule(ctx);
24
+ return listSchedule(ctx, d);
25
25
 
26
26
  case 'cron:validate':
27
27
  return validateCron(d);
@@ -1,4 +1,5 @@
1
1
  import type { ConsoleRpcExtendedCtx, EndpointManagementPort, ExtendedRpcResult } from './contracts.js';
2
+ import type { EndpointManagementCapability } from '@zhin.js/console-protocol';
2
3
  import { boolField, numField, strField, withLiveEndpoint } from './rpc-values.js';
3
4
 
4
5
  export async function listFriends(
@@ -12,7 +13,7 @@ export async function listFriends(
12
13
  const friends = [...await management.listFriends()];
13
14
  return { data: { friends, count: friends.length } };
14
15
  }
15
- return { error: `当前适配器(${endpoint.adapter})不支持好友列表` };
16
+ return unsupportedList('friends', 'listFriends', endpoint.adapter, '好友列表');
16
17
  });
17
18
  }
18
19
 
@@ -27,7 +28,7 @@ export async function listGroups(
27
28
  const groups = [...await management.listGroups()];
28
29
  return { data: { groups, count: groups.length } };
29
30
  }
30
- return { error: `当前适配器(${endpoint.adapter})不支持群列表` };
31
+ return unsupportedList('groups', 'listGroups', endpoint.adapter, '群列表');
31
32
  });
32
33
  }
33
34
 
@@ -42,7 +43,7 @@ export async function listChannels(
42
43
  const channels = [...await management.listChannels()];
43
44
  return { data: { channels, count: channels.length } };
44
45
  }
45
- return { error: `当前适配器(${endpoint.adapter})不支持频道列表` };
46
+ return unsupportedList('channels', 'listChannels', endpoint.adapter, '频道列表');
46
47
  });
47
48
  }
48
49
 
@@ -59,13 +60,36 @@ export async function listGroupMembers(
59
60
  return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
60
61
  const method = management.listGroupMembers;
61
62
  if (!method) {
62
- return { error: `当前适配器(${adapter})不支持群成员列表` };
63
+ return unsupportedList('members', 'listGroupMembers', adapter, '群成员列表');
63
64
  }
64
65
  const members = [...await method.call(management, { groupId })];
65
66
  return { data: { members, count: members.length } };
66
67
  });
67
68
  }
68
69
 
70
+ /**
71
+ * A read the Adapter does not declare is not a failure (#1250): reply with an empty list plus
72
+ * `status: 'unsupported'` (`ConsoleEndpointCapabilityUnsupported`) instead of `{ error }`, so the
73
+ * Console can render "该平台不提供" rather than a red read error. Older clients still see an empty
74
+ * list with `count: 0`.
75
+ */
76
+ function unsupportedList(
77
+ key: 'friends' | 'groups' | 'channels' | 'members',
78
+ capability: EndpointManagementCapability,
79
+ adapter: string,
80
+ label: string,
81
+ ): ExtendedRpcResult {
82
+ return {
83
+ data: {
84
+ [key]: [],
85
+ count: 0,
86
+ status: 'unsupported',
87
+ capability,
88
+ message: `当前适配器(${adapter})不支持${label}`,
89
+ },
90
+ };
91
+ }
92
+
69
93
  // ---------------------------------------------------------------- 群管/好友写操作
70
94
 
71
95
  interface GroupWriteSpec {
@@ -1,6 +1,7 @@
1
1
  export type {
2
2
  ConsoleRpcExtendedCtx,
3
3
  ConsoleScheduleEngine,
4
+ ConsoleScheduleTarget,
4
5
  ConsoleWorkroomKnowledgeControlPort,
5
6
  ConsoleWorkroomProfileControlPort,
6
7
  ExtendedRpcResult,
@@ -6,7 +6,32 @@ const CRON_NOT_WIRED =
6
6
  '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
7
7
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
8
8
 
9
- export function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcResult> | ExtendedRpcResult {
9
+ /** `titleI18n[locale]` → `titleI18n[language]` → `title`. */
10
+ function localizedTitle(job: ScheduleJobRow, locale: string | undefined): string | undefined {
11
+ const map = job.titleI18n && typeof job.titleI18n === 'object' ? job.titleI18n : undefined;
12
+ if (locale && map) {
13
+ const exact = map[locale];
14
+ if (typeof exact === 'string' && exact.trim()) return exact.trim();
15
+ const language = locale.split('-')[0]!;
16
+ const byLanguage = map[language];
17
+ if (typeof byLanguage === 'string' && byLanguage.trim()) return byLanguage.trim();
18
+ }
19
+ return typeof job.title === 'string' && job.title.trim() ? job.title.trim() : undefined;
20
+ }
21
+
22
+ function lastRunFields(job: ScheduleJobRow): Record<string, unknown> {
23
+ const run = job.lastRun;
24
+ if (!run || typeof run !== 'object' || typeof run.startedAt !== 'number') return {};
25
+ return {
26
+ lastExecutedAt: run.startedAt,
27
+ ...(typeof run.finishedAt === 'number' ? { lastFinishedAt: run.finishedAt, lastDurationMs: Math.max(0, run.finishedAt - run.startedAt) } : {}),
28
+ lastStatus: run.status === 'failed' ? 'failed' : 'succeeded',
29
+ ...(run.status === 'failed' && typeof run.error === 'string' ? { lastError: run.error } : {}),
30
+ };
31
+ }
32
+
33
+ export function listSchedule(ctx: ConsoleScheduleListCtx, d: Record<string, unknown> = {}): Promise<ExtendedRpcResult> | ExtendedRpcResult {
34
+ const locale = typeof d.locale === 'string' && d.locale.trim() ? d.locale.trim() : undefined;
10
35
  const host = ctx.scheduleHost as { list?: () => unknown } | undefined;
11
36
  if (!host || typeof host.list !== 'function') {
12
37
  return { error: '调度服务未配置(scheduleHost 未挂载)' };
@@ -18,17 +43,26 @@ export function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcRe
18
43
  !!job && typeof job === 'object'
19
44
  && typeof (job as ScheduleJobRow).id === 'string'
20
45
  && typeof (job as ScheduleJobRow).cron === 'string')
21
- .map((job) => ({
46
+ .map((job) => {
47
+ const title = localizedTitle(job, locale);
48
+ return {
22
49
  id: job.id,
23
50
  cron: job.cron,
24
51
  ...(typeof job.description === 'string' ? { description: job.description } : {}),
52
+ // #1250: plugin polling is not a user reminder; title is localized, displayName never falls back to a bare id when a title exists.
53
+ kind: job.kind === 'user-task' ? 'user-task' : 'plugin-internal',
54
+ ...(title ? { title } : {}),
55
+ ...(job.titleI18n && typeof job.titleI18n === 'object' ? { titleI18n: job.titleI18n } : {}),
56
+ displayName: title ?? (typeof job.description === 'string' && job.description ? job.description : job.id),
57
+ ...lastRunFields(job),
25
58
  // console cron 页渲染字段(由 schedule host 提供时透传)
26
59
  expression: (job as { expression?: string }).expression ?? job.cron,
27
60
  running: (job as { running?: boolean }).running ?? true,
28
61
  ...((job as { plugin?: string }).plugin ? { plugin: (job as { plugin?: string }).plugin } : {}),
29
62
  ...((job as { nextExecution?: number }).nextExecution
30
63
  ? { nextExecution: (job as { nextExecution?: number }).nextExecution } : {}),
31
- }));
64
+ };
65
+ });
32
66
  return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
33
67
  } catch (error) {
34
68
  return { error: errorMessage(error) };
@@ -50,10 +84,14 @@ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<st
50
84
  prompt: (job.action as { prompt?: string } | undefined)?.prompt ?? '',
51
85
  enabled: job.enabled !== false,
52
86
  source: job.source ?? 'manual',
87
+ // Persistent jobs are created by a person or on their behalf by the Agent.
88
+ kind: 'user-task',
53
89
  createdAt: (job as { createdAt?: number }).createdAt,
54
90
  nextExecution: (job.state as { nextRunAtMs?: number } | undefined)?.nextRunAtMs,
55
91
  ...((job.notify as { target?: unknown } | undefined)?.target
56
92
  ? { context: { target: (job.notify as { target?: unknown }).target } } : {}),
93
+ // #1250: the resolved delivery target; `resolved: false` = results cannot be delivered.
94
+ ...(ctx.describeScheduleTarget ? { target: ctx.describeScheduleTarget(job.notify) } : {}),
57
95
  lastExecutedAt: (job.state as { lastExecutedAt?: number } | undefined)?.lastExecutedAt,
58
96
  lastStatus: (job.state as { lastStatus?: string } | undefined)?.lastStatus,
59
97
  }));
@@ -62,6 +100,22 @@ async function listPersistentJobs(ctx: ConsoleRpcExtendedCtx): Promise<Record<st
62
100
  }
63
101
  }
64
102
 
103
+ /** The account (`adapter` / `endpoint`) a schedule delivery target sends from, if it names one. */
104
+ function deliveryAccount(target: unknown): { adapter: string; endpoint: string } | undefined {
105
+ if (typeof target === 'string') {
106
+ // `<adapter>:<endpoint>:<kind>:<id>`
107
+ const [adapter, endpoint] = target.split(':');
108
+ return adapter && endpoint ? { adapter, endpoint } : undefined;
109
+ }
110
+ if (!target || typeof target !== 'object') return undefined;
111
+ const scene = (target as { scene?: unknown }).scene;
112
+ if (!scene || typeof scene !== 'object') return undefined;
113
+ const { platform, endpointKey } = scene as { platform?: unknown; endpointKey?: unknown };
114
+ return typeof platform === 'string' && platform && typeof endpointKey === 'string' && endpointKey
115
+ ? { adapter: platform, endpoint: endpointKey }
116
+ : undefined;
117
+ }
118
+
65
119
  export async function addCron(
66
120
  d: Record<string, unknown>,
67
121
  ctx: ConsoleRpcExtendedCtx,
@@ -80,6 +134,16 @@ export async function addCron(
80
134
  const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
81
135
  const context = (d.context ?? {}) as Record<string, unknown>;
82
136
  const target = context.target ?? context.channel;
137
+ // A delivery target sends to that account on every run: `endpoint.operate` there, like
138
+ // `/message/send`, on top of `ops.schedule.write` (absent check = trusted in-process caller).
139
+ if (target && ctx.canOperateEndpoint) {
140
+ const account = deliveryAccount(target);
141
+ if (!account || !ctx.canOperateEndpoint(account.adapter, account.endpoint)) {
142
+ return { error: account
143
+ ? `没有向该账号投递的权限:需要 ${account.adapter} / ${account.endpoint} 上的 endpoint.operate`
144
+ : '投递目标需要指明账号(scene.platform 与 scene.endpointKey)' };
145
+ }
146
+ }
83
147
  const job = {
84
148
  id: `console-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
85
149
  ...(label ? { label } : {}),
@@ -1,7 +1,11 @@
1
1
  import {
2
2
  CONFIG_PATCH_LIMITS,
3
+ CONSOLE_SEND_FAILURE_MESSAGES,
3
4
  ENDPOINT_RPC,
5
+ classifyConsoleSendFailure,
4
6
  type ConsoleConfigApplicationStatus,
7
+ type ConsoleSendFailure,
8
+ type ConsoleSendFailureStatus,
5
9
  } from '@zhin.js/console-protocol';
6
10
  import type {
7
11
  ConversationAddress,
@@ -30,6 +34,43 @@ export function wireConversation(
30
34
  };
31
35
  }
32
36
 
37
+ const SEND_FAILURE_STATUSES: ReadonlySet<string> = new Set(['failed', 'rejected', 'unsupported', 'suppressed']);
38
+
39
+ /**
40
+ * Structural read of an outbound failure (#1250): Core's `EndpointDeliveryError` carries `code`,
41
+ * `disposition` and the canonical receipt; host/http must not import Core, so only those fields are
42
+ * read. `endpoint not found` is the Endpoint directory's plain error. An error counts as a send
43
+ * failure only when its `code` is a known delivery code or it carries a `disposition` / `receipt`;
44
+ * anything else (a Node `ENOENT`, a database error, …) is unclassified and keeps its own message.
45
+ */
46
+ export function consoleSendFailure(error: unknown): ConsoleSendFailure | undefined {
47
+ if (!(error instanceof Error)) return undefined;
48
+ const record = error as Error & { code?: unknown; disposition?: unknown; receipt?: unknown };
49
+ if (typeof record.code !== 'string' || !record.code) {
50
+ return error.message === 'endpoint not found'
51
+ ? { code: 'endpoint_not_found', reason: 'endpoint_not_found', status: 'failed' }
52
+ : undefined;
53
+ }
54
+ // Every known delivery code maps to a reason other than the catch-all `failed`.
55
+ const knownCode = classifyConsoleSendFailure(record.code) !== 'failed';
56
+ const delivery = typeof record.disposition === 'string' || (!!record.receipt && typeof record.receipt === 'object');
57
+ if (!knownCode && !delivery) return undefined;
58
+ const receipt = record.receipt && typeof record.receipt === 'object'
59
+ ? record.receipt as { status?: unknown; failure?: { retryable?: unknown; deliveryUnknown?: unknown } }
60
+ : undefined;
61
+ const status: ConsoleSendFailureStatus = record.disposition === 'unknown' || receipt?.failure?.deliveryUnknown === true
62
+ ? 'unknown'
63
+ : typeof receipt?.status === 'string' && SEND_FAILURE_STATUSES.has(receipt.status)
64
+ ? receipt.status as ConsoleSendFailureStatus
65
+ : record.disposition === 'rejected' ? 'rejected' : 'failed';
66
+ return {
67
+ code: record.code,
68
+ reason: classifyConsoleSendFailure(record.code, status),
69
+ status,
70
+ ...(receipt?.failure?.retryable === true ? { retryable: true } : {}),
71
+ };
72
+ }
73
+
33
74
  const FORBIDDEN_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
34
75
 
35
76
  /**
@@ -145,12 +186,12 @@ export async function dispatchHostResourceConsoleRpc(
145
186
  emit({ requestId, error: 'filename is required' });
146
187
  return true;
147
188
  }
148
- if (!ctx.readEnvFile) {
189
+ // Masked only (issue #1250): the file as written is never answered here.
190
+ if (!ctx.readEnvFileView) {
149
191
  emit({ requestId, error: 'Env read is not configured' });
150
192
  return true;
151
193
  }
152
- const content = await ctx.readEnvFile(filename);
153
- emit({ requestId, data: { content } });
194
+ emit({ requestId, data: await ctx.readEnvFileView(filename) });
154
195
  } catch (error) {
155
196
  emit({
156
197
  requestId,
@@ -159,23 +200,70 @@ export async function dispatchHostResourceConsoleRpc(
159
200
  }
160
201
  return true;
161
202
  }
203
+ case 'env:reveal': {
204
+ try {
205
+ const filename = String(message.filename ?? '');
206
+ const key = message.key;
207
+ if (!filename) {
208
+ emit({ requestId, error: 'filename is required' });
209
+ return true;
210
+ }
211
+ if (typeof key !== 'string' || !key) {
212
+ emit({ requestId, error: 'key is required' });
213
+ return true;
214
+ }
215
+ if (!ctx.revealEnvVariable) {
216
+ emit({ requestId, error: 'Env reveal is not configured' });
217
+ return true;
218
+ }
219
+ emit({ requestId, data: await ctx.revealEnvVariable(filename, key) });
220
+ } catch (error) {
221
+ emit({
222
+ requestId,
223
+ error: `Failed to reveal env variable: ${error instanceof Error ? error.message : String(error)}`,
224
+ });
225
+ }
226
+ return true;
227
+ }
162
228
  case 'env:save': {
163
229
  try {
164
230
  const filename = String(message.filename ?? '');
165
- const content = message.content;
166
231
  if (!filename) {
167
232
  emit({ requestId, error: 'filename is required' });
168
233
  return true;
169
234
  }
170
- if (typeof content !== 'string') {
171
- emit({ requestId, error: 'content field is required' });
235
+ const hasChanges = message.changes !== undefined;
236
+ const hasContent = message.content !== undefined;
237
+ if (hasChanges && hasContent) {
238
+ emit({ requestId, error: 'changes 与 content 只能提供一个' });
172
239
  return true;
173
240
  }
174
- if (!ctx.writeEnvFile) {
241
+ if (hasChanges) {
242
+ if (!ctx.updateEnvVariables) {
243
+ emit({ requestId, error: 'Env write is not configured' });
244
+ return true;
245
+ }
246
+ const { changed } = await ctx.updateEnvVariables(filename, message.changes);
247
+ emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}`, changed } });
248
+ return true;
249
+ }
250
+ if (typeof message.content !== 'string') {
251
+ emit({ requestId, error: 'changes field is required' });
252
+ return true;
253
+ }
254
+ // The whole-file form must be asked for: an older Console sends back what `env:get` showed it.
255
+ if (message.mode !== 'replace') {
256
+ emit({
257
+ requestId,
258
+ error: '此 Host 只接受逐键保存(changes);整文件保存需显式声明 mode: "replace"。请升级 Console 后重试',
259
+ });
260
+ return true;
261
+ }
262
+ if (!ctx.replaceEnvFile) {
175
263
  emit({ requestId, error: 'Env write is not configured' });
176
264
  return true;
177
265
  }
178
- await ctx.writeEnvFile(filename, content);
266
+ await ctx.replaceEnvFile(filename, message.content);
179
267
  emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}` } });
180
268
  } catch (error) {
181
269
  emit({
@@ -555,9 +643,11 @@ export async function dispatchHostResourceConsoleRpc(
555
643
  });
556
644
  emit({ requestId, data: { messageId: result.messageId } });
557
645
  } catch (error) {
646
+ const failure = consoleSendFailure(error);
558
647
  emit({
559
648
  requestId,
560
- error: error instanceof Error ? error.message : String(error),
649
+ error: failure ? CONSOLE_SEND_FAILURE_MESSAGES[failure.reason] : error instanceof Error ? error.message : String(error),
650
+ ...(failure ? { data: failure } : {}),
561
651
  });
562
652
  }
563
653
  return true;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * RPC types the runtime Console dispatcher of this package answers (`dispatchRuntimeConsoleRpc`:
3
+ * shell, configuration, plugin management, project files / environment / database, endpoints,
4
+ * schedules, inbox, login assist, workrooms). Used to tell an RPC this Host has but forgot to map
5
+ * (audited `unmapped`) from one it does not have at all (`unknown_rpc`, zhinjs/zhin#1250), and to
6
+ * build the Host's capability list. `console-rpc-types.test.ts` keeps it in step with the
7
+ * dispatchers' `case` labels.
8
+ */
9
+ import { CONFIG_RPC, ENDPOINT_RPC, PLUGIN_RPC } from '@zhin.js/console-protocol';
10
+
11
+ export const RUNTIME_CONSOLE_RPC_TYPES: ReadonlySet<string> = new Set<string>([
12
+ // Shell
13
+ 'ping', 'entries:get', 'pages:list',
14
+ // Configuration
15
+ ...Object.values(CONFIG_RPC),
16
+ 'workrooms:get', 'workrooms:set',
17
+ // Plugin management (the global switch is refused with a pointer to the gateway)
18
+ ...Object.values(PLUGIN_RPC),
19
+ // Project files / environment / schemas / database / process
20
+ 'files:tree', 'files:read', 'files:save',
21
+ 'env:list', 'env:get', 'env:reveal', 'env:save',
22
+ 'schema:get', 'schema:get-all',
23
+ 'db:info', 'db:tables', 'db:select', 'db:query', 'db:execute',
24
+ 'db:schema:describe', 'db:schema:preview', 'db:schema:apply',
25
+ 'db:insert', 'db:update', 'db:delete', 'db:drop-table',
26
+ 'db:kv:get', 'db:kv:set', 'db:kv:delete', 'db:kv:entries',
27
+ 'system:restart',
28
+ // Endpoints
29
+ ...Object.values(ENDPOINT_RPC),
30
+ // Schedules
31
+ 'schedule:list', 'cron:list', 'cron:validate', 'cron:add', 'cron:remove', 'cron:pause', 'cron:resume',
32
+ // Requests / notices / inbox
33
+ 'request.list', 'request.approve', 'request.reject', 'request.consumed', 'notice.consumed',
34
+ 'inbox.requests', 'inbox.notices', 'inbox.messages', 'inbox.recent',
35
+ // Login assist
36
+ 'login.list', 'login.submit', 'login.cancel', 'login.flow.list', 'login.flow.get', 'login.relogin', 'login.refresh',
37
+ // Workroom profiles / knowledge
38
+ 'workroom.profile.status', 'workroom.profile.bootstrap', 'workroom.profile.pack.publish',
39
+ 'workroom.profile.publish', 'workroom.profile.rollback', 'workroom.profile.policy.publish',
40
+ 'workroom.knowledge.get', 'workroom.knowledge.publish', 'workroom.knowledge.rollback',
41
+ ]);
42
+
43
+ export function isRuntimeConsoleRpcType(type: unknown): type is string {
44
+ return typeof type === 'string' && RUNTIME_CONSOLE_RPC_TYPES.has(type);
45
+ }
@@ -3,6 +3,7 @@ import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
3
3
  import {
4
4
  CONFIG_PATCH_LIMITS,
5
5
  CONFIG_RPC,
6
+ CONSOLE_UNKNOWN_RPC_CODE,
6
7
  grantsAllow,
7
8
  type ConsoleConfigPatchRequest,
8
9
  type ConsoleConfigPatchResult,
@@ -12,6 +13,7 @@ import {
12
13
  type ConsoleEndpointSummary,
13
14
  } from '@zhin.js/console-protocol';
14
15
  import { authorizeConsoleRpc, principalAllows, type ConsolePrincipal } from './access-control.js';
16
+ import { isRuntimeConsoleRpcType } from './console-rpc-types.js';
15
17
  import { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
16
18
  import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
17
19
  import {
@@ -128,9 +130,21 @@ export interface PluginVerificationResult {
128
130
  readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
129
131
  }
130
132
 
133
+ /** `inspect` options. `dependencies: 'skip'` leaves the project-wide graph check out (`dependencies: 'unchecked'`). */
134
+ export interface PluginInspectOptions {
135
+ readonly dependencies?: 'check' | 'skip';
136
+ }
137
+
138
+ /** Result of the project-wide dependency graph check (the same check `inspect` runs per plugin). */
139
+ export type PluginDependencyCheck =
140
+ | { readonly status: 'passed' }
141
+ | { readonly status: 'failed'; readonly error: string };
142
+
131
143
  export interface PluginManagementPort {
132
144
  explainConfiguration?(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
133
- inspect?(packageName: string): Promise<PluginManagementState>;
145
+ inspect?(packageName: string, options?: PluginInspectOptions): Promise<PluginManagementState>;
146
+ /** Runs the project-wide dependency graph check once, for callers that inspect many plugins. */
147
+ checkDependencies?(): Promise<PluginDependencyCheck>;
134
148
  apply?(packageName: string, expectedProjectRevision?: string): Promise<{ readonly requested: boolean; readonly state: PluginManagementState }>;
135
149
  verificationPlan?(packageName: string): Promise<PluginVerificationPlan>;
136
150
  verify?(packageName: string, checkId: string, expectedGeneration: number | null, expectedProjectRevision: string, expectedCheckRevision: string): Promise<PluginVerificationResult>;
@@ -209,8 +223,21 @@ export type RuntimeConsoleRpcContext = {
209
223
  readProjectFile?(filePath: string): Promise<{ content: string; size: number }>;
210
224
  saveProjectFile?(filePath: string, content: string): Promise<void>;
211
225
  listEnvFiles?(): Promise<readonly { name: string; exists: boolean }[]>;
226
+ /**
227
+ * @deprecated Not used any more: `env:get` answers {@link readEnvFileView} only, so the file as
228
+ * written never reaches the Console (issue #1250).
229
+ */
212
230
  readEnvFile?(filename: string): Promise<string>;
231
+ /** @deprecated Not used any more: `env:save` writes through {@link updateEnvVariables} / {@link replaceEnvFile}. */
213
232
  writeEnvFile?(filename: string, content: string): Promise<void>;
233
+ /** `env:get`: masked view (key names, masks, lengths; comments and blank lines in place). */
234
+ readEnvFileView?(filename: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvFileView>;
235
+ /** `env:reveal`: one variable as written. The route requires `ops.env.write` + `ops.env.read` and audits. */
236
+ revealEnvVariable?(filename: string, key: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvRevealResult>;
237
+ /** `env:save { changes }`: validates and writes only the named keys (mask placeholders refused). */
238
+ updateEnvVariables?(filename: string, changes: unknown): Promise<{ readonly changed: readonly string[] }>;
239
+ /** `env:save { content, mode: 'replace' }`: whole file; refused when it holds mask placeholders. */
240
+ replaceEnvFile?(filename: string, content: string): Promise<void>;
214
241
  /** Optional schema accessors (plugin schema.json / Runtime schema registry). */
215
242
  getSchema?(pluginName?: string): Promise<unknown>;
216
243
  getAllSchemas?(): Promise<Record<string, unknown>>;
@@ -288,9 +315,9 @@ export async function dispatchRuntimeConsoleRpc(
288
315
  let canOperateEndpoint: ConsoleRpcExtendedCtx['canOperateEndpoint'];
289
316
  if (ctx.principal) {
290
317
  const principal = ctx.principal;
291
- const decision = authorizeConsoleRpc(principal, message as Record<string, unknown>);
318
+ const decision = authorizeConsoleRpc(principal, message as Record<string, unknown>, { registered: isRuntimeConsoleRpcType });
292
319
  if (!decision.allowed) {
293
- emit({ requestId, error: decision.error, code: decision.code });
320
+ emit({ requestId, error: decision.error, code: decision.code, ...(decision.reason ? { reason: decision.reason } : {}) });
294
321
  return payloads;
295
322
  }
296
323
  authorizedEndpoint = scopedAuthorizedEndpoint(principal, decision);
@@ -618,7 +645,7 @@ export async function dispatchRuntimeConsoleRpc(
618
645
  });
619
646
  return payloads;
620
647
  }
621
- emit({ requestId, error: `Unknown RPC type: ${type || '<empty>'}` });
648
+ emit({ requestId, error: `Unknown RPC type: ${type.slice(0, 64) || '<empty>'}`, code: CONSOLE_UNKNOWN_RPC_CODE });
622
649
  return payloads;
623
650
  }
624
651
  }