@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.
- package/lib/access-control.d.ts +31 -3
- package/lib/access-control.js +96 -10
- package/lib/console-rest-pages-helpers.js +24 -2
- package/lib/console-rest-pages.d.ts +4 -1
- package/lib/console-rest-pages.js +49 -23
- package/lib/console-rpc-extended/contracts.d.ts +22 -0
- package/lib/console-rpc-extended/dispatcher.js +1 -1
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.js +78 -12
- package/lib/console-rpc-handlers.d.ts +9 -1
- package/lib/console-rpc-handlers.js +97 -10
- package/lib/console-rpc-types.d.ts +2 -0
- package/lib/console-rpc-types.js +43 -0
- package/lib/console-rpc.d.ts +29 -1
- package/lib/console-rpc.js +5 -4
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +5 -3
- package/lib/index.js +3 -1
- package/lib/project-files.d.ts +7 -0
- package/lib/project-files.js +88 -11
- package/lib/unknown-rpc.d.ts +12 -0
- package/lib/unknown-rpc.js +33 -0
- package/package.json +3 -3
- package/src/access-control.ts +132 -11
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +18 -0
- package/src/console-rpc-extended/dispatcher.ts +1 -1
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +1 -0
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +99 -9
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +31 -4
- package/src/http-host.ts +6 -2
- package/src/index.ts +10 -0
- package/src/project-files.ts +95 -11
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
439
|
-
keywords: (p.tags
|
|
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 = [
|
|
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
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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 (
|
|
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.
|
|
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
|
+
}
|
package/src/console-rpc.ts
CHANGED
|
@@ -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
|
}
|