@zhin.js/host-http 1.0.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.
@@ -0,0 +1,759 @@
1
+ import type { AuthScope } from './token-registry.js';
2
+ import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
3
+ import { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx } from './console-rpc-extended.js';
4
+
5
+ export type RuntimeConsoleRpcMessage = {
6
+ readonly type?: unknown;
7
+ readonly requestId?: unknown;
8
+ readonly [key: string]: unknown;
9
+ };
10
+
11
+ export type RuntimeConsoleRpcReply = Record<string, unknown>;
12
+
13
+ export type RuntimeConsolePage = {
14
+ readonly id: string;
15
+ readonly localName: string;
16
+ readonly title: string;
17
+ readonly route: string;
18
+ readonly module: string;
19
+ readonly order: number;
20
+ readonly hash: string;
21
+ };
22
+
23
+ export type RuntimeConsoleRpcContext = {
24
+ readonly authScope: AuthScope;
25
+ listPages(): Promise<readonly RuntimeConsolePage[]>;
26
+ /** Optional project config accessors for read-only config RPCs. */
27
+ readConfigYaml?(): Promise<string>;
28
+ listPluginKeys?(): Promise<readonly string[]>;
29
+ readConfigDocument?(): Promise<Record<string, unknown>>;
30
+ /**
31
+ * Full-scope write: replace project config file contents (YAML or JSON text).
32
+ * Runtime Host does not hot-reload the process — callers should restart.
33
+ */
34
+ writeConfigYaml?(yaml: string): Promise<void>;
35
+ /**
36
+ * Full-scope write: set `document[pluginName] = data` and persist.
37
+ * Returns whether a process restart is required (always true without ConfigFeature).
38
+ */
39
+ setConfigKey?(pluginName: string, data: unknown): Promise<{ restartRequired: boolean }>;
40
+ /** Project file manager (allowlisted paths under project root). */
41
+ listProjectFiles?(): Promise<readonly unknown[]>;
42
+ readProjectFile?(filePath: string): Promise<{ content: string; size: number }>;
43
+ saveProjectFile?(filePath: string, content: string): Promise<void>;
44
+ listEnvFiles?(): Promise<readonly { name: string; exists: boolean }[]>;
45
+ readEnvFile?(filename: string): Promise<string>;
46
+ writeEnvFile?(filename: string, content: string): Promise<void>;
47
+ /** Optional schema accessors (plugin schema.json / Runtime schema registry). */
48
+ getSchema?(pluginName?: string): Promise<unknown>;
49
+ getAllSchemas?(): Promise<Record<string, unknown>>;
50
+ /** Optional Adapter endpoint Console surface (Sandbox / Remote Console). */
51
+ listEndpoints?(): Promise<readonly RuntimeEndpointSummary[]>;
52
+ getEndpoint?(adapter: string, endpointId: string): Promise<RuntimeEndpointSummary | null>;
53
+ sendEndpointMessage?(input: RuntimeEndpointSendInput): Promise<{ messageId: string }>;
54
+ /** Optional Database host surface (CLI wires DatabaseHost from plugin-runtime). */
55
+ dbInfo?(): Promise<RuntimeDatabaseInfo> | RuntimeDatabaseInfo;
56
+ dbTables?(): Promise<readonly string[]> | readonly string[];
57
+ database?: DatabaseHostConsole;
58
+ /** Full-scope: request process restart (CLI daemon watches exit code). */
59
+ requestRestart?(): Promise<void> | void;
60
+ /** Extended RPC surface(cron/schedule、endpoint 社交/inbox),fullScope 由 authScope 推导。 */
61
+ extended?: Omit<ConsoleRpcExtendedCtx, 'fullScope'>;
62
+ };
63
+
64
+ export type RuntimeEndpointPhase =
65
+ 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
66
+
67
+ export type RuntimeEndpointSummary = {
68
+ readonly name: string;
69
+ readonly adapter: string;
70
+ readonly connected: boolean;
71
+ readonly status: 'online' | 'offline';
72
+ readonly phase?: RuntimeEndpointPhase;
73
+ readonly pendingLogin?: boolean;
74
+ };
75
+
76
+ export type RuntimeDatabaseInfo = {
77
+ readonly dialect: string | null;
78
+ readonly connected: boolean;
79
+ readonly tables: number;
80
+ };
81
+
82
+ export type RuntimeEndpointSendInput = {
83
+ readonly adapter: string;
84
+ readonly endpointId: string;
85
+ readonly channelId: string;
86
+ readonly channelType: string;
87
+ readonly content: unknown;
88
+ readonly parent?: { type?: string; id?: string; name?: string };
89
+ };
90
+
91
+ /**
92
+ * Frontend SDK (@zhin.js/client) calls colon-named aliases; normalize to the
93
+ * canonical dot names before scope checks and dispatch. Alias parameter shapes
94
+ * (`data.{adapter, endpointId, id, type, content, parent}`) are mapped onto the
95
+ * `$`-prefixed fields inside each endpoint case.
96
+ */
97
+ const RPC_TYPE_ALIASES: Readonly<Record<string, string>> = Object.freeze({
98
+ 'endpoint:list': 'endpoint.list',
99
+ 'endpoint:info': 'endpoint.info',
100
+ 'endpoint:sendMessage': 'endpoint.send_message',
101
+ });
102
+
103
+ /** ADR 0016 demo allowlist subset available on Plugin Runtime Host. */
104
+ const DEMO_RPC_ALLOWLIST = new Set([
105
+ 'ping',
106
+ 'entries:get',
107
+ 'pages:list',
108
+ 'config:get',
109
+ 'config:get-all',
110
+ 'config:get-yaml',
111
+ 'schema:get',
112
+ 'schema:get-all',
113
+ 'endpoint.list',
114
+ 'endpoint.info',
115
+ 'endpoint.send_message',
116
+ ]);
117
+
118
+ const DEMO_RPC_WRITE_BLOCKLIST = new Set([
119
+ 'config:set',
120
+ 'config:save-yaml',
121
+ 'files:save',
122
+ 'env:save',
123
+ 'db:insert',
124
+ 'db:update',
125
+ 'db:delete',
126
+ 'db:drop-table',
127
+ 'db:kv:set',
128
+ 'db:kv:delete',
129
+ 'system:restart',
130
+ ]);
131
+
132
+ export function pickRpcReply(
133
+ message: RuntimeConsoleRpcMessage,
134
+ payloads: readonly RuntimeConsoleRpcReply[],
135
+ ): RuntimeConsoleRpcReply | null {
136
+ const requestId = message.requestId;
137
+ return (
138
+ (requestId != null ? payloads.find((payload) => payload.requestId === requestId) : undefined)
139
+ ?? payloads[payloads.length - 1]
140
+ ?? null
141
+ );
142
+ }
143
+
144
+ export async function dispatchRuntimeConsoleRpc(
145
+ message: RuntimeConsoleRpcMessage,
146
+ ctx: RuntimeConsoleRpcContext,
147
+ ): Promise<RuntimeConsoleRpcReply[]> {
148
+ const payloads: RuntimeConsoleRpcReply[] = [];
149
+ const emit = (payload: RuntimeConsoleRpcReply) => {
150
+ payloads.push(payload);
151
+ };
152
+ const type = RPC_TYPE_ALIASES[String(message.type ?? '')] ?? String(message.type ?? '');
153
+ const requestId = message.requestId as number | string | undefined;
154
+
155
+ // Extended RPC surface (cron/schedule、endpoint 社交/inbox) — 自带 scope 矩阵
156
+ if (ctx.extended) {
157
+ const extended = await dispatchExtendedConsoleRpc(
158
+ type,
159
+ message.data as Record<string, unknown> | undefined,
160
+ { ...ctx.extended, fullScope: ctx.authScope === 'full' },
161
+ );
162
+ if (extended !== undefined) {
163
+ emit('error' in extended
164
+ ? { requestId, error: extended.error }
165
+ : { requestId, data: extended.data });
166
+ return payloads;
167
+ }
168
+ }
169
+
170
+ if (ctx.authScope === 'demo' && (DEMO_RPC_WRITE_BLOCKLIST.has(type) || !DEMO_RPC_ALLOWLIST.has(type))) {
171
+ emit({ requestId, error: `Demo scope: RPC "${type}" is forbidden` });
172
+ return payloads;
173
+ }
174
+
175
+ switch (type) {
176
+ case 'ping':
177
+ emit({ type: 'pong', requestId });
178
+ return payloads;
179
+ case 'entries:get': {
180
+ const pages = await ctx.listPages();
181
+ emit({
182
+ requestId,
183
+ data: pages.map((page) => Object.freeze({
184
+ id: page.localName,
185
+ module: page.module,
186
+ order: page.order,
187
+ enabled: true,
188
+ meta: Object.freeze({ name: page.title }),
189
+ route: page.route,
190
+ hash: page.hash,
191
+ })),
192
+ });
193
+ return payloads;
194
+ }
195
+ case 'pages:list': {
196
+ const pages = await ctx.listPages();
197
+ emit({ requestId, data: pages });
198
+ return payloads;
199
+ }
200
+ case 'config:get-yaml': {
201
+ try {
202
+ const yaml = ctx.readConfigYaml ? await ctx.readConfigYaml() : '';
203
+ const pluginKeys = ctx.listPluginKeys ? await ctx.listPluginKeys() : [];
204
+ emit({ requestId, data: { yaml, pluginKeys } });
205
+ } catch (error) {
206
+ emit({
207
+ requestId,
208
+ error: `Failed to read config: ${error instanceof Error ? error.message : String(error)}`,
209
+ });
210
+ }
211
+ return payloads;
212
+ }
213
+ case 'config:get-all': {
214
+ try {
215
+ const document = ctx.readConfigDocument ? await ctx.readConfigDocument() : {};
216
+ emit({ requestId, data: document });
217
+ } catch (error) {
218
+ emit({
219
+ requestId,
220
+ error: `Failed to read config: ${error instanceof Error ? error.message : String(error)}`,
221
+ });
222
+ }
223
+ return payloads;
224
+ }
225
+ case 'config:get': {
226
+ try {
227
+ const key = String(message.key ?? message.pluginName ?? '');
228
+ const document = ctx.readConfigDocument ? await ctx.readConfigDocument() : {};
229
+ emit({ requestId, data: key ? document[key] : document });
230
+ } catch (error) {
231
+ emit({
232
+ requestId,
233
+ error: `Failed to read config: ${error instanceof Error ? error.message : String(error)}`,
234
+ });
235
+ }
236
+ return payloads;
237
+ }
238
+ case 'config:save-yaml': {
239
+ try {
240
+ const yaml = message.yaml;
241
+ if (typeof yaml !== 'string') {
242
+ emit({ requestId, error: 'yaml field is required' });
243
+ return payloads;
244
+ }
245
+ if (!ctx.writeConfigYaml) {
246
+ emit({ requestId, error: 'Config write is not configured' });
247
+ return payloads;
248
+ }
249
+ await ctx.writeConfigYaml(yaml);
250
+ emit({
251
+ requestId,
252
+ data: { success: true, message: '配置已保存,需重启生效' },
253
+ });
254
+ } catch (error) {
255
+ emit({
256
+ requestId,
257
+ error: `Failed to save config: ${error instanceof Error ? error.message : String(error)}`,
258
+ });
259
+ }
260
+ return payloads;
261
+ }
262
+ case 'config:set': {
263
+ try {
264
+ const pluginName = message.pluginName;
265
+ if (typeof pluginName !== 'string' || !pluginName) {
266
+ emit({ requestId, error: 'Plugin name is required' });
267
+ return payloads;
268
+ }
269
+ if (!ctx.setConfigKey) {
270
+ emit({ requestId, error: 'Config write is not configured' });
271
+ return payloads;
272
+ }
273
+ const result = await ctx.setConfigKey(pluginName, message.data);
274
+ emit({
275
+ requestId,
276
+ data: {
277
+ success: true,
278
+ reloaded: false,
279
+ message: result.restartRequired
280
+ ? '配置已保存,需重启进程才能生效'
281
+ : '配置已保存',
282
+ },
283
+ });
284
+ } catch (error) {
285
+ emit({
286
+ requestId,
287
+ error: `Failed to set config: ${error instanceof Error ? error.message : String(error)}`,
288
+ });
289
+ }
290
+ return payloads;
291
+ }
292
+ case 'files:tree': {
293
+ try {
294
+ const tree = ctx.listProjectFiles ? await ctx.listProjectFiles() : [];
295
+ emit({ requestId, data: { tree } });
296
+ } catch (error) {
297
+ emit({
298
+ requestId,
299
+ error: `Failed to build file tree: ${error instanceof Error ? error.message : String(error)}`,
300
+ });
301
+ }
302
+ return payloads;
303
+ }
304
+ case 'files:read': {
305
+ try {
306
+ const filePath = String(message.filePath ?? '');
307
+ if (!filePath) {
308
+ emit({ requestId, error: 'filePath is required' });
309
+ return payloads;
310
+ }
311
+ if (!ctx.readProjectFile) {
312
+ emit({ requestId, error: 'File read is not configured' });
313
+ return payloads;
314
+ }
315
+ const file = await ctx.readProjectFile(filePath);
316
+ emit({ requestId, data: file });
317
+ } catch (error) {
318
+ emit({
319
+ requestId,
320
+ error: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`,
321
+ });
322
+ }
323
+ return payloads;
324
+ }
325
+ case 'files:save': {
326
+ try {
327
+ const filePath = String(message.filePath ?? '');
328
+ const content = message.content;
329
+ if (!filePath) {
330
+ emit({ requestId, error: 'filePath is required' });
331
+ return payloads;
332
+ }
333
+ if (typeof content !== 'string') {
334
+ emit({ requestId, error: 'content field is required' });
335
+ return payloads;
336
+ }
337
+ if (!ctx.saveProjectFile) {
338
+ emit({ requestId, error: 'File write is not configured' });
339
+ return payloads;
340
+ }
341
+ await ctx.saveProjectFile(filePath, content);
342
+ emit({ requestId, data: { success: true, message: `文件已保存: ${filePath}` } });
343
+ } catch (error) {
344
+ emit({
345
+ requestId,
346
+ error: `Failed to save file: ${error instanceof Error ? error.message : String(error)}`,
347
+ });
348
+ }
349
+ return payloads;
350
+ }
351
+ case 'env:list': {
352
+ try {
353
+ const files = ctx.listEnvFiles ? await ctx.listEnvFiles() : [];
354
+ emit({ requestId, data: { files } });
355
+ } catch (error) {
356
+ emit({
357
+ requestId,
358
+ error: `Failed to list env files: ${error instanceof Error ? error.message : String(error)}`,
359
+ });
360
+ }
361
+ return payloads;
362
+ }
363
+ case 'env:get': {
364
+ try {
365
+ const filename = String(message.filename ?? '');
366
+ if (!filename) {
367
+ emit({ requestId, error: 'filename is required' });
368
+ return payloads;
369
+ }
370
+ if (!ctx.readEnvFile) {
371
+ emit({ requestId, error: 'Env read is not configured' });
372
+ return payloads;
373
+ }
374
+ const content = await ctx.readEnvFile(filename);
375
+ emit({ requestId, data: { content } });
376
+ } catch (error) {
377
+ emit({
378
+ requestId,
379
+ error: `Failed to read env file: ${error instanceof Error ? error.message : String(error)}`,
380
+ });
381
+ }
382
+ return payloads;
383
+ }
384
+ case 'env:save': {
385
+ try {
386
+ const filename = String(message.filename ?? '');
387
+ const content = message.content;
388
+ if (!filename) {
389
+ emit({ requestId, error: 'filename is required' });
390
+ return payloads;
391
+ }
392
+ if (typeof content !== 'string') {
393
+ emit({ requestId, error: 'content field is required' });
394
+ return payloads;
395
+ }
396
+ if (!ctx.writeEnvFile) {
397
+ emit({ requestId, error: 'Env write is not configured' });
398
+ return payloads;
399
+ }
400
+ await ctx.writeEnvFile(filename, content);
401
+ emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}` } });
402
+ } catch (error) {
403
+ emit({
404
+ requestId,
405
+ error: `Failed to save env file: ${error instanceof Error ? error.message : String(error)}`,
406
+ });
407
+ }
408
+ return payloads;
409
+ }
410
+ case 'schema:get': {
411
+ try {
412
+ const pluginName = typeof message.pluginName === 'string' ? message.pluginName : undefined;
413
+ const schema = ctx.getSchema ? await ctx.getSchema(pluginName) : null;
414
+ emit({ requestId, data: schema });
415
+ } catch (error) {
416
+ emit({
417
+ requestId,
418
+ error: `Failed to get schema: ${error instanceof Error ? error.message : String(error)}`,
419
+ });
420
+ }
421
+ return payloads;
422
+ }
423
+ case 'schema:get-all': {
424
+ try {
425
+ const schemas = ctx.getAllSchemas ? await ctx.getAllSchemas() : {};
426
+ emit({ requestId, data: schemas });
427
+ } catch (error) {
428
+ emit({
429
+ requestId,
430
+ error: `Failed to get all schemas: ${error instanceof Error ? error.message : String(error)}`,
431
+ });
432
+ }
433
+ return payloads;
434
+ }
435
+ case 'db:info': {
436
+ if (ctx.database) {
437
+ emit({ requestId, data: ctx.database.info() });
438
+ return payloads;
439
+ }
440
+ if (ctx.dbInfo) {
441
+ try {
442
+ emit({ requestId, data: await ctx.dbInfo() });
443
+ } catch (error) {
444
+ emit({
445
+ requestId,
446
+ error: error instanceof Error ? error.message : String(error),
447
+ });
448
+ }
449
+ return payloads;
450
+ }
451
+ emit({
452
+ requestId,
453
+ data: { dialect: null, connected: false, message: 'Database not configured on Plugin Runtime Host' },
454
+ });
455
+ return payloads;
456
+ }
457
+ case 'db:tables': {
458
+ try {
459
+ if (ctx.database) {
460
+ emit({ requestId, data: { tables: ctx.database.tables() } });
461
+ return payloads;
462
+ }
463
+ const tables = ctx.dbTables ? await ctx.dbTables() : [];
464
+ emit({ requestId, data: tables });
465
+ } catch (error) {
466
+ emit({
467
+ requestId,
468
+ error: error instanceof Error ? error.message : String(error),
469
+ });
470
+ }
471
+ return payloads;
472
+ }
473
+ case 'db:select': {
474
+ const table = stringField(message, 'table');
475
+ if (!table) return errorReply(payloads, requestId, 'table is required');
476
+ const page = positiveInteger(message.page, 1);
477
+ const pageSize = Math.min(positiveInteger(message.pageSize, 50), 500);
478
+ const where = recordField(message.where);
479
+ if (!ctx.database) {
480
+ emit({ requestId, data: { rows: [], total: 0, page, pageSize } });
481
+ return payloads;
482
+ }
483
+ try {
484
+ emit({ requestId, data: await ctx.database.select(table, page, pageSize, where) });
485
+ } catch (error) {
486
+ emit({ requestId, error: `Failed to select: ${errorMessage(error)}` });
487
+ }
488
+ return payloads;
489
+ }
490
+ case 'db:insert': {
491
+ if (!ctx.database) return errorReply(payloads, requestId, 'Database RPC is not available on Plugin Runtime Host');
492
+ const table = stringField(message, 'table');
493
+ const row = recordField(message.row);
494
+ if (!table || !row) return errorReply(payloads, requestId, 'table and row are required');
495
+ try {
496
+ await ctx.database.insert(table, row);
497
+ emit({ requestId, data: { success: true } });
498
+ } catch (error) {
499
+ emit({ requestId, error: `Failed to insert: ${errorMessage(error)}` });
500
+ }
501
+ return payloads;
502
+ }
503
+ case 'db:update': {
504
+ if (!ctx.database) return errorReply(payloads, requestId, 'Database RPC is not available on Plugin Runtime Host');
505
+ const table = stringField(message, 'table');
506
+ const row = recordField(message.row);
507
+ const where = recordField(message.where);
508
+ if (!table || !row || !where) {
509
+ return errorReply(payloads, requestId, 'table, row, and where are required');
510
+ }
511
+ try {
512
+ const affected = await ctx.database.update(table, row, where);
513
+ emit({ requestId, data: { success: true, affected } });
514
+ } catch (error) {
515
+ emit({ requestId, error: `Failed to update: ${errorMessage(error)}` });
516
+ }
517
+ return payloads;
518
+ }
519
+ case 'db:delete': {
520
+ if (!ctx.database) return errorReply(payloads, requestId, 'Database RPC is not available on Plugin Runtime Host');
521
+ const table = stringField(message, 'table');
522
+ const where = recordField(message.where);
523
+ if (!table || !where) return errorReply(payloads, requestId, 'table and where are required');
524
+ try {
525
+ const deleted = await ctx.database.delete(table, where);
526
+ emit({ requestId, data: { success: true, deleted } });
527
+ } catch (error) {
528
+ emit({ requestId, error: `Failed to delete: ${errorMessage(error)}` });
529
+ }
530
+ return payloads;
531
+ }
532
+ case 'db:drop-table': {
533
+ if (!ctx.database) return errorReply(payloads, requestId, 'Database RPC is not available on Plugin Runtime Host');
534
+ const table = stringField(message, 'table');
535
+ if (!table) return errorReply(payloads, requestId, 'table is required');
536
+ try {
537
+ await ctx.database.dropTable(table);
538
+ emit({ requestId, data: { success: true } });
539
+ } catch (error) {
540
+ emit({ requestId, error: `Failed to drop table: ${errorMessage(error)}` });
541
+ }
542
+ return payloads;
543
+ }
544
+ case 'db:kv:get': {
545
+ const table = stringField(message, 'table');
546
+ const key = stringField(message, 'key');
547
+ if (!table || !key) return errorReply(payloads, requestId, 'table and key are required');
548
+ if (!ctx.database) {
549
+ emit({ requestId, data: { key, value: null } });
550
+ return payloads;
551
+ }
552
+ try {
553
+ emit({ requestId, data: { key, value: await ctx.database.kvGet(table, key) } });
554
+ } catch (error) {
555
+ emit({ requestId, error: `Failed to get kv: ${errorMessage(error)}` });
556
+ }
557
+ return payloads;
558
+ }
559
+ case 'db:kv:set': {
560
+ if (!ctx.database) return errorReply(payloads, requestId, 'Database RPC is not available on Plugin Runtime Host');
561
+ const table = stringField(message, 'table');
562
+ const key = stringField(message, 'key');
563
+ if (!table || !key) return errorReply(payloads, requestId, 'table and key are required');
564
+ try {
565
+ const ttl = typeof message.ttl === 'number' && Number.isFinite(message.ttl)
566
+ ? message.ttl
567
+ : undefined;
568
+ await ctx.database.kvSet(table, key, message.value, ttl);
569
+ emit({ requestId, data: { success: true } });
570
+ } catch (error) {
571
+ emit({ requestId, error: `Failed to set kv: ${errorMessage(error)}` });
572
+ }
573
+ return payloads;
574
+ }
575
+ case 'db:kv:delete': {
576
+ if (!ctx.database) return errorReply(payloads, requestId, 'Database RPC is not available on Plugin Runtime Host');
577
+ const table = stringField(message, 'table');
578
+ const key = stringField(message, 'key');
579
+ if (!table || !key) return errorReply(payloads, requestId, 'table and key are required');
580
+ try {
581
+ await ctx.database.kvDelete(table, key);
582
+ emit({ requestId, data: { success: true } });
583
+ } catch (error) {
584
+ emit({ requestId, error: `Failed to delete kv: ${errorMessage(error)}` });
585
+ }
586
+ return payloads;
587
+ }
588
+ case 'db:kv:entries': {
589
+ const table = stringField(message, 'table');
590
+ if (!table) return errorReply(payloads, requestId, 'table is required');
591
+ if (!ctx.database) {
592
+ emit({ requestId, data: { entries: [] } });
593
+ return payloads;
594
+ }
595
+ try {
596
+ emit({ requestId, data: { entries: await ctx.database.kvEntries(table) } });
597
+ } catch (error) {
598
+ emit({ requestId, error: `Failed to get entries: ${errorMessage(error)}` });
599
+ }
600
+ return payloads;
601
+ }
602
+ case 'endpoint.list': {
603
+ try {
604
+ const endpoints = ctx.listEndpoints ? await ctx.listEndpoints() : [];
605
+ emit({ requestId, data: { endpoints } });
606
+ } catch (error) {
607
+ emit({
608
+ requestId,
609
+ error: error instanceof Error ? error.message : String(error),
610
+ });
611
+ }
612
+ return payloads;
613
+ }
614
+ case 'endpoint.info': {
615
+ try {
616
+ const data = (message.data ?? {}) as Record<string, unknown>;
617
+ // Accept both canonical `$`-fields and the SDK colon-alias shape.
618
+ const adapter = String(data.$adapter ?? data.adapter ?? '');
619
+ const endpointId = String(data.$endpoint ?? data.endpointId ?? '');
620
+ if (!adapter || !endpointId) {
621
+ emit({ requestId, error: '$adapter and $endpoint required' });
622
+ return payloads;
623
+ }
624
+ if (!ctx.getEndpoint) {
625
+ emit({ requestId, error: 'Endpoint registry is not configured' });
626
+ return payloads;
627
+ }
628
+ const endpoint = await ctx.getEndpoint(adapter, endpointId);
629
+ if (!endpoint) {
630
+ emit({ requestId, error: 'endpoint not found' });
631
+ return payloads;
632
+ }
633
+ emit({ requestId, data: endpoint });
634
+ } catch (error) {
635
+ emit({
636
+ requestId,
637
+ error: error instanceof Error ? error.message : String(error),
638
+ });
639
+ }
640
+ return payloads;
641
+ }
642
+ case 'endpoint.send_message': {
643
+ try {
644
+ const data = (message.data ?? {}) as Record<string, unknown>;
645
+ // Accept both canonical `$`-fields and the SDK colon-alias shape
646
+ // (`endpoint:sendMessage` → data.{adapter, endpointId, id, type, content, parent}).
647
+ const adapter = String(data.$adapter ?? data.adapter ?? '');
648
+ const endpointId = String(data.$endpoint ?? data.endpointId ?? '');
649
+ const channelId = String(data.$channel_id ?? data.id ?? '');
650
+ const channelType = String(data.$channel_type ?? data.type ?? '');
651
+ const content = data.$content !== undefined ? data.$content : data.content;
652
+ // channelType is optional: ImRuntime tolerates an empty value and
653
+ // targets the bare channel id (parity with the legacy RPC handler).
654
+ if (!adapter || !endpointId || !channelId || content === undefined) {
655
+ emit({
656
+ requestId,
657
+ error: '$adapter, $endpoint, $channel_id, $content required',
658
+ });
659
+ return payloads;
660
+ }
661
+ if (!ctx.sendEndpointMessage) {
662
+ emit({ requestId, error: 'Endpoint send is not configured' });
663
+ return payloads;
664
+ }
665
+ const result = await ctx.sendEndpointMessage({
666
+ adapter,
667
+ endpointId,
668
+ channelId,
669
+ channelType,
670
+ content,
671
+ parent: (data.$parent ?? data.parent) as
672
+ { type?: string; id?: string; name?: string } | undefined,
673
+ });
674
+ // Legacy contract: { message_id }. Keep messageId for new callers.
675
+ emit({ requestId, data: { message_id: result.messageId, messageId: result.messageId } });
676
+ } catch (error) {
677
+ emit({
678
+ requestId,
679
+ error: error instanceof Error ? error.message : String(error),
680
+ });
681
+ }
682
+ return payloads;
683
+ }
684
+ case 'system:restart': {
685
+ try {
686
+ if (!ctx.requestRestart) {
687
+ emit({ requestId, error: 'Process restart is not configured' });
688
+ return payloads;
689
+ }
690
+ emit({ requestId, data: { success: true, message: '正在重启...' } });
691
+ // Delay so the HTTP response can flush (parity with legacy host-api).
692
+ setTimeout(() => {
693
+ void Promise.resolve(ctx.requestRestart?.()).catch(() => {
694
+ /* restart is best-effort */
695
+ });
696
+ }, 500);
697
+ } catch (error) {
698
+ emit({
699
+ requestId,
700
+ error: error instanceof Error ? error.message : String(error),
701
+ });
702
+ }
703
+ return payloads;
704
+ }
705
+ default:
706
+ if (type.startsWith('db:')) {
707
+ emit({
708
+ requestId,
709
+ error: 'Database RPC is not available on Plugin Runtime Host yet',
710
+ });
711
+ return payloads;
712
+ }
713
+ emit({ requestId, error: `Unknown RPC type: ${type || '<empty>'}` });
714
+ return payloads;
715
+ }
716
+ }
717
+
718
+ function stringField(message: RuntimeConsoleRpcMessage, key: string): string {
719
+ const value = message[key];
720
+ return typeof value === 'string' ? value.trim() : '';
721
+ }
722
+
723
+ function recordField(value: unknown): Record<string, unknown> | undefined {
724
+ return value && typeof value === 'object' && !Array.isArray(value)
725
+ ? value as Record<string, unknown>
726
+ : undefined;
727
+ }
728
+
729
+ function positiveInteger(value: unknown, fallback: number): number {
730
+ const parsed = Number(value ?? fallback);
731
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
732
+ }
733
+
734
+ function errorMessage(error: unknown): string {
735
+ return error instanceof Error ? error.message : String(error);
736
+ }
737
+
738
+ function errorReply(
739
+ payloads: RuntimeConsoleRpcReply[],
740
+ requestId: number | string | undefined,
741
+ error: string,
742
+ ): RuntimeConsoleRpcReply[] {
743
+ payloads.push({ requestId, error });
744
+ return payloads;
745
+ }
746
+
747
+ /** HTTP paths under apiBase that demo scope may call (ADR 0016). */
748
+ export function isDemoHttpAllowed(method: string, pathname: string, apiBase: string): boolean {
749
+ const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
750
+ const m = method.toUpperCase();
751
+ if (m === 'GET' && pathname === `${normalizedBase}/events`) return true;
752
+ if (m === 'POST' && pathname === `${normalizedBase}/console/request`) return true;
753
+ // Read-only dashboard / plugin inspection routes (legacy host-api parity).
754
+ if (m === 'GET' && pathname === `${normalizedBase}/system/status`) return true;
755
+ if (m === 'GET' && pathname === `${normalizedBase}/stats`) return true;
756
+ if (m === 'GET' && (pathname === `${normalizedBase}/plugins`
757
+ || pathname.startsWith(`${normalizedBase}/plugins/`))) return true;
758
+ return false;
759
+ }