@zhin.js/host-http 1.2.2 → 1.4.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 (47) hide show
  1. package/lib/access-control.d.ts +41 -3
  2. package/lib/access-control.js +112 -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 +108 -12
  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 +13 -5
  18. package/lib/host-installer.js +10 -5
  19. package/lib/http-host.js +5 -4
  20. package/lib/index.d.ts +6 -3
  21. package/lib/index.js +4 -1
  22. package/lib/node/listener.js +2 -1
  23. package/lib/port.d.ts +27 -0
  24. package/lib/port.js +68 -0
  25. package/lib/project-files.d.ts +7 -0
  26. package/lib/project-files.js +88 -11
  27. package/lib/unknown-rpc.d.ts +12 -0
  28. package/lib/unknown-rpc.js +33 -0
  29. package/package.json +4 -4
  30. package/src/access-control.ts +151 -11
  31. package/src/console-rest-pages-helpers.ts +27 -3
  32. package/src/console-rest-pages.ts +52 -15
  33. package/src/console-rpc-extended/contracts.ts +18 -0
  34. package/src/console-rpc-extended/dispatcher.ts +1 -1
  35. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  36. package/src/console-rpc-extended/index.ts +1 -0
  37. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  38. package/src/console-rpc-handlers.ts +110 -11
  39. package/src/console-rpc-types.ts +45 -0
  40. package/src/console-rpc.ts +39 -5
  41. package/src/host-installer.ts +10 -4
  42. package/src/http-host.ts +6 -2
  43. package/src/index.ts +21 -0
  44. package/src/node/listener.ts +2 -1
  45. package/src/port.ts +60 -0
  46. package/src/project-files.ts +95 -11
  47. package/src/unknown-rpc.ts +44 -0
@@ -2,7 +2,33 @@ import { parseCron } from '@zhin.js/schedule';
2
2
  import { errorMessage } from './rpc-values.js';
3
3
  const CRON_NOT_WIRED = '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
4
4
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
5
- export function listSchedule(ctx) {
5
+ /** `titleI18n[locale]` → `titleI18n[language]` → `title`. */
6
+ function localizedTitle(job, locale) {
7
+ const map = job.titleI18n && typeof job.titleI18n === 'object' ? job.titleI18n : undefined;
8
+ if (locale && map) {
9
+ const exact = map[locale];
10
+ if (typeof exact === 'string' && exact.trim())
11
+ return exact.trim();
12
+ const language = locale.split('-')[0];
13
+ const byLanguage = map[language];
14
+ if (typeof byLanguage === 'string' && byLanguage.trim())
15
+ return byLanguage.trim();
16
+ }
17
+ return typeof job.title === 'string' && job.title.trim() ? job.title.trim() : undefined;
18
+ }
19
+ function lastRunFields(job) {
20
+ const run = job.lastRun;
21
+ if (!run || typeof run !== 'object' || typeof run.startedAt !== 'number')
22
+ return {};
23
+ return {
24
+ lastExecutedAt: run.startedAt,
25
+ ...(typeof run.finishedAt === 'number' ? { lastFinishedAt: run.finishedAt, lastDurationMs: Math.max(0, run.finishedAt - run.startedAt) } : {}),
26
+ lastStatus: run.status === 'failed' ? 'failed' : 'succeeded',
27
+ ...(run.status === 'failed' && typeof run.error === 'string' ? { lastError: run.error } : {}),
28
+ };
29
+ }
30
+ export function listSchedule(ctx, d = {}) {
31
+ const locale = typeof d.locale === 'string' && d.locale.trim() ? d.locale.trim() : undefined;
6
32
  const host = ctx.scheduleHost;
7
33
  if (!host || typeof host.list !== 'function') {
8
34
  return { error: '调度服务未配置(scheduleHost 未挂载)' };
@@ -13,17 +39,26 @@ export function listSchedule(ctx) {
13
39
  .filter((job) => !!job && typeof job === 'object'
14
40
  && typeof job.id === 'string'
15
41
  && typeof job.cron === 'string')
16
- .map((job) => ({
17
- id: job.id,
18
- cron: job.cron,
19
- ...(typeof job.description === 'string' ? { description: job.description } : {}),
20
- // console cron 页渲染字段(由 schedule host 提供时透传)
21
- expression: job.expression ?? job.cron,
22
- running: job.running ?? true,
23
- ...(job.plugin ? { plugin: job.plugin } : {}),
24
- ...(job.nextExecution
25
- ? { nextExecution: job.nextExecution } : {}),
26
- }));
42
+ .map((job) => {
43
+ const title = localizedTitle(job, locale);
44
+ return {
45
+ id: job.id,
46
+ cron: job.cron,
47
+ ...(typeof job.description === 'string' ? { description: job.description } : {}),
48
+ // #1250: plugin polling is not a user reminder; title is localized, displayName never falls back to a bare id when a title exists.
49
+ kind: job.kind === 'user-task' ? 'user-task' : 'plugin-internal',
50
+ ...(title ? { title } : {}),
51
+ ...(job.titleI18n && typeof job.titleI18n === 'object' ? { titleI18n: job.titleI18n } : {}),
52
+ displayName: title ?? (typeof job.description === 'string' && job.description ? job.description : job.id),
53
+ ...lastRunFields(job),
54
+ // console cron 页渲染字段(由 schedule host 提供时透传)
55
+ expression: job.expression ?? job.cron,
56
+ running: job.running ?? true,
57
+ ...(job.plugin ? { plugin: job.plugin } : {}),
58
+ ...(job.nextExecution
59
+ ? { nextExecution: job.nextExecution } : {}),
60
+ };
61
+ });
27
62
  return listPersistentJobs(ctx).then((persistent) => ({ data: { memory, persistent, capabilities: { persistent: !!ctx.resolveScheduleEngine?.() && ctx.fullScope } } })).catch((error) => ({ error: errorMessage(error) }));
28
63
  }
29
64
  catch (error) {
@@ -44,10 +79,14 @@ async function listPersistentJobs(ctx) {
44
79
  prompt: job.action?.prompt ?? '',
45
80
  enabled: job.enabled !== false,
46
81
  source: job.source ?? 'manual',
82
+ // Persistent jobs are created by a person or on their behalf by the Agent.
83
+ kind: 'user-task',
47
84
  createdAt: job.createdAt,
48
85
  nextExecution: job.state?.nextRunAtMs,
49
86
  ...(job.notify?.target
50
87
  ? { context: { target: job.notify.target } } : {}),
88
+ // #1250: the resolved delivery target; `resolved: false` = results cannot be delivered.
89
+ ...(ctx.describeScheduleTarget ? { target: ctx.describeScheduleTarget(job.notify) } : {}),
51
90
  lastExecutedAt: job.state?.lastExecutedAt,
52
91
  lastStatus: job.state?.lastStatus,
53
92
  }));
@@ -56,6 +95,23 @@ async function listPersistentJobs(ctx) {
56
95
  throw new Error(`读取持久化任务失败: ${errorMessage(error)}`);
57
96
  }
58
97
  }
98
+ /** The account (`adapter` / `endpoint`) a schedule delivery target sends from, if it names one. */
99
+ function deliveryAccount(target) {
100
+ if (typeof target === 'string') {
101
+ // `<adapter>:<endpoint>:<kind>:<id>`
102
+ const [adapter, endpoint] = target.split(':');
103
+ return adapter && endpoint ? { adapter, endpoint } : undefined;
104
+ }
105
+ if (!target || typeof target !== 'object')
106
+ return undefined;
107
+ const scene = target.scene;
108
+ if (!scene || typeof scene !== 'object')
109
+ return undefined;
110
+ const { platform, endpointKey } = scene;
111
+ return typeof platform === 'string' && platform && typeof endpointKey === 'string' && endpointKey
112
+ ? { adapter: platform, endpoint: endpointKey }
113
+ : undefined;
114
+ }
59
115
  export async function addCron(d, ctx) {
60
116
  const engine = ctx.resolveScheduleEngine?.();
61
117
  if (!engine)
@@ -75,6 +131,16 @@ export async function addCron(d, ctx) {
75
131
  const label = typeof d.label === 'string' && d.label.trim() ? d.label.trim() : undefined;
76
132
  const context = (d.context ?? {});
77
133
  const target = context.target ?? context.channel;
134
+ // A delivery target sends to that account on every run: `endpoint.operate` there, like
135
+ // `/message/send`, on top of `ops.schedule.write` (absent check = trusted in-process caller).
136
+ if (target && ctx.canOperateEndpoint) {
137
+ const account = deliveryAccount(target);
138
+ if (!account || !ctx.canOperateEndpoint(account.adapter, account.endpoint)) {
139
+ return { error: account
140
+ ? `没有向该账号投递的权限:需要 ${account.adapter} / ${account.endpoint} 上的 endpoint.operate`
141
+ : '投递目标需要指明账号(scene.platform 与 scene.endpointKey)' };
142
+ }
143
+ }
78
144
  const job = {
79
145
  id: `console-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
80
146
  ...(label ? { label } : {}),
@@ -1,7 +1,15 @@
1
- import { type ConsoleConfigApplicationStatus } from '@zhin.js/console-protocol';
1
+ import { type ConsoleConfigApplicationStatus, type ConsoleSendFailure } from '@zhin.js/console-protocol';
2
2
  import type { ConversationAddress, RuntimeConsoleRpcContext, RuntimeConsoleRpcMessage, RuntimeConsoleRpcReply } from './console-rpc.js';
3
3
  /** Canonical Console wire fields → runtime conversation address. */
4
4
  export declare function wireConversation(channelType: string, channelId: string, parent: unknown): ConversationAddress;
5
+ /**
6
+ * Structural read of an outbound failure (#1250): Core's `EndpointDeliveryError` carries `code`,
7
+ * `disposition` and the canonical receipt; host/http must not import Core, so only those fields are
8
+ * read. `endpoint not found` is the Endpoint directory's plain error. An error counts as a send
9
+ * failure only when its `code` is a known delivery code or it carries a `disposition` / `receipt`;
10
+ * anything else (a Node `ENOENT`, a database error, …) is unclassified and keeps its own message.
11
+ */
12
+ export declare function consoleSendFailure(error: unknown): ConsoleSendFailure | undefined;
5
13
  /**
6
14
  * Structural check for `config:patch-project` operations at the RPC boundary, so no project
7
15
  * port implementation receives a malformed request. The store repeats the full validation.
@@ -1,4 +1,4 @@
1
- import { CONFIG_PATCH_LIMITS, ENDPOINT_RPC, } from '@zhin.js/console-protocol';
1
+ import { CONFIG_PATCH_LIMITS, CONSOLE_SEND_FAILURE_MESSAGES, ENDPOINT_RPC, classifyConsoleSendFailure, endpointTransportLabel, } from '@zhin.js/console-protocol';
2
2
  /** Canonical Console wire fields → runtime conversation address. */
3
3
  export function wireConversation(channelType, channelId, parent) {
4
4
  const kind = channelType === 'group' || channelType === 'channel' ? channelType : 'private';
@@ -13,6 +13,43 @@ export function wireConversation(channelType, channelId, parent) {
13
13
  ...(parentKind && parentId ? { parent: { kind: parentKind, id: parentId } } : {}),
14
14
  };
15
15
  }
16
+ const SEND_FAILURE_STATUSES = new Set(['failed', 'rejected', 'unsupported', 'suppressed']);
17
+ /**
18
+ * Structural read of an outbound failure (#1250): Core's `EndpointDeliveryError` carries `code`,
19
+ * `disposition` and the canonical receipt; host/http must not import Core, so only those fields are
20
+ * read. `endpoint not found` is the Endpoint directory's plain error. An error counts as a send
21
+ * failure only when its `code` is a known delivery code or it carries a `disposition` / `receipt`;
22
+ * anything else (a Node `ENOENT`, a database error, …) is unclassified and keeps its own message.
23
+ */
24
+ export function consoleSendFailure(error) {
25
+ if (!(error instanceof Error))
26
+ return undefined;
27
+ const record = error;
28
+ if (typeof record.code !== 'string' || !record.code) {
29
+ return error.message === 'endpoint not found'
30
+ ? { code: 'endpoint_not_found', reason: 'endpoint_not_found', status: 'failed' }
31
+ : undefined;
32
+ }
33
+ // Every known delivery code maps to a reason other than the catch-all `failed`.
34
+ const knownCode = classifyConsoleSendFailure(record.code) !== 'failed';
35
+ const delivery = typeof record.disposition === 'string' || (!!record.receipt && typeof record.receipt === 'object');
36
+ if (!knownCode && !delivery)
37
+ return undefined;
38
+ const receipt = record.receipt && typeof record.receipt === 'object'
39
+ ? record.receipt
40
+ : undefined;
41
+ const status = record.disposition === 'unknown' || receipt?.failure?.deliveryUnknown === true
42
+ ? 'unknown'
43
+ : typeof receipt?.status === 'string' && SEND_FAILURE_STATUSES.has(receipt.status)
44
+ ? receipt.status
45
+ : record.disposition === 'rejected' ? 'rejected' : 'failed';
46
+ return {
47
+ code: record.code,
48
+ reason: classifyConsoleSendFailure(record.code, status),
49
+ status,
50
+ ...(receipt?.failure?.retryable === true ? { retryable: true } : {}),
51
+ };
52
+ }
16
53
  const FORBIDDEN_PATCH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
17
54
  /**
18
55
  * Structural check for `config:patch-project` operations at the RPC boundary, so no project
@@ -129,12 +166,12 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
129
166
  emit({ requestId, error: 'filename is required' });
130
167
  return true;
131
168
  }
132
- if (!ctx.readEnvFile) {
169
+ // Masked only (issue #1250): the file as written is never answered here.
170
+ if (!ctx.readEnvFileView) {
133
171
  emit({ requestId, error: 'Env read is not configured' });
134
172
  return true;
135
173
  }
136
- const content = await ctx.readEnvFile(filename);
137
- emit({ requestId, data: { content } });
174
+ emit({ requestId, data: await ctx.readEnvFileView(filename) });
138
175
  }
139
176
  catch (error) {
140
177
  emit({
@@ -144,23 +181,71 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
144
181
  }
145
182
  return true;
146
183
  }
184
+ case 'env:reveal': {
185
+ try {
186
+ const filename = String(message.filename ?? '');
187
+ const key = message.key;
188
+ if (!filename) {
189
+ emit({ requestId, error: 'filename is required' });
190
+ return true;
191
+ }
192
+ if (typeof key !== 'string' || !key) {
193
+ emit({ requestId, error: 'key is required' });
194
+ return true;
195
+ }
196
+ if (!ctx.revealEnvVariable) {
197
+ emit({ requestId, error: 'Env reveal is not configured' });
198
+ return true;
199
+ }
200
+ emit({ requestId, data: await ctx.revealEnvVariable(filename, key) });
201
+ }
202
+ catch (error) {
203
+ emit({
204
+ requestId,
205
+ error: `Failed to reveal env variable: ${error instanceof Error ? error.message : String(error)}`,
206
+ });
207
+ }
208
+ return true;
209
+ }
147
210
  case 'env:save': {
148
211
  try {
149
212
  const filename = String(message.filename ?? '');
150
- const content = message.content;
151
213
  if (!filename) {
152
214
  emit({ requestId, error: 'filename is required' });
153
215
  return true;
154
216
  }
155
- if (typeof content !== 'string') {
156
- emit({ requestId, error: 'content field is required' });
217
+ const hasChanges = message.changes !== undefined;
218
+ const hasContent = message.content !== undefined;
219
+ if (hasChanges && hasContent) {
220
+ emit({ requestId, error: 'changes 与 content 只能提供一个' });
221
+ return true;
222
+ }
223
+ if (hasChanges) {
224
+ if (!ctx.updateEnvVariables) {
225
+ emit({ requestId, error: 'Env write is not configured' });
226
+ return true;
227
+ }
228
+ const { changed } = await ctx.updateEnvVariables(filename, message.changes);
229
+ emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}`, changed } });
230
+ return true;
231
+ }
232
+ if (typeof message.content !== 'string') {
233
+ emit({ requestId, error: 'changes field is required' });
234
+ return true;
235
+ }
236
+ // The whole-file form must be asked for: an older Console sends back what `env:get` showed it.
237
+ if (message.mode !== 'replace') {
238
+ emit({
239
+ requestId,
240
+ error: '此 Host 只接受逐键保存(changes);整文件保存需显式声明 mode: "replace"。请升级 Console 后重试',
241
+ });
157
242
  return true;
158
243
  }
159
- if (!ctx.writeEnvFile) {
244
+ if (!ctx.replaceEnvFile) {
160
245
  emit({ requestId, error: 'Env write is not configured' });
161
246
  return true;
162
247
  }
163
- await ctx.writeEnvFile(filename, content);
248
+ await ctx.replaceEnvFile(filename, message.content);
164
249
  emit({ requestId, data: { success: true, message: `环境文件已保存: ${filename}` } });
165
250
  }
166
251
  catch (error) {
@@ -510,7 +595,7 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
510
595
  case 'endpoint.list': {
511
596
  try {
512
597
  const endpoints = ctx.listEndpoints ? await ctx.listEndpoints() : [];
513
- emit({ requestId, data: { endpoints } });
598
+ emit({ requestId, data: { endpoints: endpoints.map(withTransportLabel) } });
514
599
  }
515
600
  catch (error) {
516
601
  emit({
@@ -538,7 +623,7 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
538
623
  emit({ requestId, error: 'endpoint not found' });
539
624
  return true;
540
625
  }
541
- emit({ requestId, data: endpoint });
626
+ emit({ requestId, data: withTransportLabel(endpoint) });
542
627
  }
543
628
  catch (error) {
544
629
  emit({
@@ -628,9 +713,11 @@ export async function dispatchHostResourceConsoleRpc(type, message, ctx, request
628
713
  emit({ requestId, data: { messageId: result.messageId } });
629
714
  }
630
715
  catch (error) {
716
+ const failure = consoleSendFailure(error);
631
717
  emit({
632
718
  requestId,
633
- error: error instanceof Error ? error.message : String(error),
719
+ error: failure ? CONSOLE_SEND_FAILURE_MESSAGES[failure.reason] : error instanceof Error ? error.message : String(error),
720
+ ...(failure ? { data: failure } : {}),
634
721
  });
635
722
  }
636
723
  return true;
@@ -741,3 +828,12 @@ export function configurationSaveMessage(result) {
741
828
  }
742
829
  return result.restartRequired ? '配置已保存,需重启进程才能生效' : '配置已保存';
743
830
  }
831
+ /** Adds the readable `transportLabel` next to the raw `transportState` of an Endpoint row (#1250). */
832
+ function withTransportLabel(endpoint) {
833
+ if (!endpoint || typeof endpoint !== 'object')
834
+ return endpoint;
835
+ const state = endpoint.transportState;
836
+ if (typeof state !== 'string')
837
+ return endpoint;
838
+ return { ...endpoint, transportLabel: endpointTransportLabel(state) };
839
+ }
@@ -0,0 +1,2 @@
1
+ export declare const RUNTIME_CONSOLE_RPC_TYPES: ReadonlySet<string>;
2
+ export declare function isRuntimeConsoleRpcType(type: unknown): type is string;
@@ -0,0 +1,43 @@
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
+ export const RUNTIME_CONSOLE_RPC_TYPES = new Set([
11
+ // Shell
12
+ 'ping', 'entries:get', 'pages:list',
13
+ // Configuration
14
+ ...Object.values(CONFIG_RPC),
15
+ 'workrooms:get', 'workrooms:set',
16
+ // Plugin management (the global switch is refused with a pointer to the gateway)
17
+ ...Object.values(PLUGIN_RPC),
18
+ // Project files / environment / schemas / database / process
19
+ 'files:tree', 'files:read', 'files:save',
20
+ 'env:list', 'env:get', 'env:reveal', 'env:save',
21
+ 'schema:get', 'schema:get-all',
22
+ 'db:info', 'db:tables', 'db:select', 'db:query', 'db:execute',
23
+ 'db:schema:describe', 'db:schema:preview', 'db:schema:apply',
24
+ 'db:insert', 'db:update', 'db:delete', 'db:drop-table',
25
+ 'db:kv:get', 'db:kv:set', 'db:kv:delete', 'db:kv:entries',
26
+ 'system:restart',
27
+ // Endpoints
28
+ ...Object.values(ENDPOINT_RPC),
29
+ // Schedules
30
+ 'schedule:list', 'cron:list', 'cron:validate', 'cron:add', 'cron:remove', 'cron:pause', 'cron:resume',
31
+ // Requests / notices / inbox
32
+ 'request.list', 'request.approve', 'request.reject', 'request.consumed', 'notice.consumed',
33
+ 'inbox.requests', 'inbox.notices', 'inbox.messages', 'inbox.recent',
34
+ // Login assist
35
+ 'login.list', 'login.submit', 'login.cancel', 'login.flow.list', 'login.flow.get', 'login.relogin', 'login.refresh',
36
+ // Workroom profiles / knowledge
37
+ 'workroom.profile.status', 'workroom.profile.bootstrap', 'workroom.profile.pack.publish',
38
+ 'workroom.profile.publish', 'workroom.profile.rollback', 'workroom.profile.policy.publish',
39
+ 'workroom.knowledge.get', 'workroom.knowledge.publish', 'workroom.knowledge.rollback',
40
+ ]);
41
+ export function isRuntimeConsoleRpcType(type) {
42
+ return typeof type === 'string' && RUNTIME_CONSOLE_RPC_TYPES.has(type);
43
+ }
@@ -101,9 +101,22 @@ export interface PluginVerificationResult {
101
101
  readonly evidence: readonly string[];
102
102
  readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
103
103
  }
104
+ /** `inspect` options. `dependencies: 'skip'` leaves the project-wide graph check out (`dependencies: 'unchecked'`). */
105
+ export interface PluginInspectOptions {
106
+ readonly dependencies?: 'check' | 'skip';
107
+ }
108
+ /** Result of the project-wide dependency graph check (the same check `inspect` runs per plugin). */
109
+ export type PluginDependencyCheck = {
110
+ readonly status: 'passed';
111
+ } | {
112
+ readonly status: 'failed';
113
+ readonly error: string;
114
+ };
104
115
  export interface PluginManagementPort {
105
116
  explainConfiguration?(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
106
- inspect?(packageName: string): Promise<PluginManagementState>;
117
+ inspect?(packageName: string, options?: PluginInspectOptions): Promise<PluginManagementState>;
118
+ /** Runs the project-wide dependency graph check once, for callers that inspect many plugins. */
119
+ checkDependencies?(): Promise<PluginDependencyCheck>;
107
120
  apply?(packageName: string, expectedProjectRevision?: string): Promise<{
108
121
  readonly requested: boolean;
109
122
  readonly state: PluginManagementState;
@@ -193,8 +206,23 @@ export type RuntimeConsoleRpcContext = {
193
206
  name: string;
194
207
  exists: boolean;
195
208
  }[]>;
209
+ /**
210
+ * @deprecated Not used any more: `env:get` answers {@link readEnvFileView} only, so the file as
211
+ * written never reaches the Console (issue #1250).
212
+ */
196
213
  readEnvFile?(filename: string): Promise<string>;
214
+ /** @deprecated Not used any more: `env:save` writes through {@link updateEnvVariables} / {@link replaceEnvFile}. */
197
215
  writeEnvFile?(filename: string, content: string): Promise<void>;
216
+ /** `env:get`: masked view (key names, masks, lengths; comments and blank lines in place). */
217
+ readEnvFileView?(filename: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvFileView>;
218
+ /** `env:reveal`: one variable as written. The route requires `ops.env.write` + `ops.env.read` and audits. */
219
+ revealEnvVariable?(filename: string, key: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvRevealResult>;
220
+ /** `env:save { changes }`: validates and writes only the named keys (mask placeholders refused). */
221
+ updateEnvVariables?(filename: string, changes: unknown): Promise<{
222
+ readonly changed: readonly string[];
223
+ }>;
224
+ /** `env:save { content, mode: 'replace' }`: whole file; refused when it holds mask placeholders. */
225
+ replaceEnvFile?(filename: string, content: string): Promise<void>;
198
226
  /** Optional schema accessors (plugin schema.json / Runtime schema registry). */
199
227
  getSchema?(pluginName?: string): Promise<unknown>;
200
228
  getAllSchemas?(): Promise<Record<string, unknown>>;
@@ -1,5 +1,6 @@
1
- import { CONFIG_PATCH_LIMITS, CONFIG_RPC, grantsAllow, } from '@zhin.js/console-protocol';
1
+ import { CONFIG_PATCH_LIMITS, CONFIG_RPC, CONSOLE_UNKNOWN_RPC_CODE, grantsAllow, } from '@zhin.js/console-protocol';
2
2
  import { authorizeConsoleRpc, principalAllows } from './access-control.js';
3
+ import { isRuntimeConsoleRpcType } from './console-rpc-types.js';
3
4
  import { dispatchExtendedConsoleRpc } from './console-rpc-extended/index.js';
4
5
  import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
5
6
  import { configurationSaveMessage, dispatchHostResourceConsoleRpc, invalidProjectPatchOperations, } from './console-rpc-handlers.js';
@@ -34,9 +35,9 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
34
35
  let canOperateEndpoint;
35
36
  if (ctx.principal) {
36
37
  const principal = ctx.principal;
37
- const decision = authorizeConsoleRpc(principal, message);
38
+ const decision = authorizeConsoleRpc(principal, message, { registered: isRuntimeConsoleRpcType });
38
39
  if (!decision.allowed) {
39
- emit({ requestId, error: decision.error, code: decision.code });
40
+ emit({ requestId, error: decision.error, code: decision.code, ...(decision.reason ? { reason: decision.reason } : {}) });
40
41
  return payloads;
41
42
  }
42
43
  authorizedEndpoint = scopedAuthorizedEndpoint(principal, decision);
@@ -282,7 +283,11 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
282
283
  return payloads;
283
284
  }
284
285
  const projectPatch = message.type === CONFIG_RPC.PATCH_PROJECT;
285
- const { instanceKey, expectedRevision, patches } = message;
286
+ const { instanceKey, expectedRevision, patches, secretStorage } = message;
287
+ if (secretStorage !== undefined && secretStorage !== 'env' && secretStorage !== 'plain') {
288
+ emit({ requestId, error: 'secretStorage 只能是 env 或 plain' });
289
+ return payloads;
290
+ }
286
291
  if (projectPatch && instanceKey !== undefined) {
287
292
  emit({ requestId, error: 'Project patches must not specify instanceKey' });
288
293
  return payloads;
@@ -317,6 +322,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
317
322
  ...(instanceKey === undefined ? {} : { instanceKey }),
318
323
  expectedRevision,
319
324
  patches,
325
+ ...(secretStorage === undefined ? {} : { secretStorage }),
320
326
  });
321
327
  }
322
328
  catch (error) {
@@ -381,9 +387,11 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
381
387
  });
382
388
  }
383
389
  catch (error) {
390
+ const code = error instanceof Error && 'code' in error && error.code === 'env-write-required' ? { code: 'env-write-required' } : {};
384
391
  emit({
385
392
  requestId,
386
393
  error: `Failed to set config: ${error instanceof Error ? error.message : String(error)}`,
394
+ ...code,
387
395
  });
388
396
  }
389
397
  return payloads;
@@ -399,7 +407,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
399
407
  });
400
408
  return payloads;
401
409
  }
402
- emit({ requestId, error: `Unknown RPC type: ${type || '<empty>'}` });
410
+ emit({ requestId, error: `Unknown RPC type: ${type.slice(0, 64) || '<empty>'}`, code: CONSOLE_UNKNOWN_RPC_CODE });
403
411
  return payloads;
404
412
  }
405
413
  }
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
3
3
  import { isAbsolute, resolve } from 'node:path';
4
4
  import { httpHostToken } from './http-host.js';
5
5
  import { parseTrustProxy } from './trust-proxy.js';
6
+ import { parseHttpPort } from './port.js';
6
7
  export async function resolveHttpConfig(config, envOverlay, projectRoot = process.cwd()) {
7
8
  const document = await readConfigDocument(config);
8
9
  if (!document || typeof document !== 'object')
@@ -11,18 +12,20 @@ export async function resolveHttpConfig(config, envOverlay, projectRoot = proces
11
12
  if (!http || typeof http !== 'object')
12
13
  return {};
13
14
  const value = expandEnvironmentValue(http, (key) => envOverlay?.[key] ?? process.env[key]);
15
+ const stored = http;
14
16
  if (value.tokens !== undefined && value.tokens !== null)
15
17
  throw new Error(HTTP_TOKENS_REMOVED_MESSAGE);
16
18
  const primary = Object.freeze({
17
19
  host: typeof value.host === 'string' ? value.host : undefined,
18
- port: typeof value.port === 'number' ? value.port : undefined,
20
+ // An invalid port is an error, never a silent fallback to the default (#1250).
21
+ port: parseHttpPort(value.port, 'http.port', stored.port),
19
22
  token: typeof value.token === 'string' ? value.token : undefined,
20
23
  corsOrigins: injectDesktopWebviewOrigins(parseStringList(value.corsOrigins), envOverlay),
21
24
  apiBase: typeof value.base === 'string' ? value.base : undefined,
22
25
  tls: await parseTlsConfig(value.tls, projectRoot),
23
26
  trustProxy: parseTrustProxy(value.trustProxy),
24
27
  });
25
- const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot);
28
+ const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot, stored.listeners);
26
29
  const listeners = await injectDesktopDeviceListener(configuredListeners, primary, envOverlay, projectRoot);
27
30
  return Object.freeze({ ...primary, ...(listeners.length > 0 ? { listeners } : {}) });
28
31
  }
@@ -71,7 +74,7 @@ async function injectDesktopDeviceListener(configured, primary, envOverlay, proj
71
74
  }, projectRoot);
72
75
  return Object.freeze([...configured, Object.freeze({ ...primary, host, port, tls })]);
73
76
  }
74
- async function parseAdditionalListeners(value, primary, projectRoot) {
77
+ async function parseAdditionalListeners(value, primary, projectRoot, stored) {
75
78
  if (value === undefined)
76
79
  return Object.freeze([]);
77
80
  if (!Array.isArray(value))
@@ -87,13 +90,15 @@ async function parseAdditionalListeners(value, primary, projectRoot) {
87
90
  if (typeof listener.host !== 'string' || !listener.host) {
88
91
  throw new Error(`http.listeners[${index}].host is required`);
89
92
  }
90
- if (typeof listener.port !== 'number') {
93
+ const storedPort = Array.isArray(stored) ? stored[index]?.port : undefined;
94
+ const port = parseHttpPort(listener.port, `http.listeners[${index}].port`, storedPort);
95
+ if (port === undefined) {
91
96
  throw new Error(`http.listeners[${index}].port is required`);
92
97
  }
93
98
  listeners.push(Object.freeze({
94
99
  ...primary,
95
100
  host: listener.host,
96
- port: listener.port,
101
+ port,
97
102
  tls: await parseTlsConfig(listener.tls, projectRoot),
98
103
  }));
99
104
  }
package/lib/http-host.js CHANGED
@@ -12,7 +12,7 @@ import { createToken, generationAdmissionBinder, } from '@zhin.js/plugin-runtime
12
12
  import { HttpBodyError, jsonResponse } from '@zhin.js/host-http-contract';
13
13
  import { TokenRegistry, extractBearerToken, } from './token-registry.js';
14
14
  import { buildOpenApiDocument, } from './openapi.js';
15
- import { auditActor, authorizeConsoleHttp, authorizeConsoleWebSocket, requestMeta, } from './access-control.js';
15
+ import { auditActor, authorizeConsoleHttp, authorizeConsoleWebSocket, requestMeta, webSocketDenialReason, } from './access-control.js';
16
16
  const logger = getLogger('http');
17
17
  export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
18
18
  const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
@@ -85,12 +85,12 @@ export function createHttpRouter(options = {}) {
85
85
  if (!principal)
86
86
  return undefined;
87
87
  if (!authorizeConsoleWebSocket(principal, pathname)) {
88
- audit(principal, remoteAddress, `ws:${pathname}`, 'denied');
88
+ audit(principal, remoteAddress, `ws:${pathname}`, 'denied', webSocketDenialReason(principal, pathname));
89
89
  return undefined;
90
90
  }
91
91
  return principal;
92
92
  }
93
- function audit(principal, remoteAddress, action, outcome) {
93
+ function audit(principal, remoteAddress, action, outcome, reason) {
94
94
  const sink = tokenRegistry.accessProvider?.audit;
95
95
  if (!sink)
96
96
  return;
@@ -99,6 +99,7 @@ export function createHttpRouter(options = {}) {
99
99
  actor: auditActor(principal),
100
100
  action,
101
101
  outcome,
102
+ ...(reason ? { detail: { reason } } : {}),
102
103
  ...(remoteAddress ? { ip: remoteAddress } : {}),
103
104
  });
104
105
  }
@@ -282,7 +283,7 @@ export function createHttpRouter(options = {}) {
282
283
  const decision = authorizeConsoleHttp(principal, method, relative);
283
284
  if (!decision.allowed) {
284
285
  if (principal)
285
- audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied');
286
+ audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied', decision.reason);
286
287
  return { ok: false, status: decision.status, code: decision.code, error: decision.error };
287
288
  }
288
289
  return { ok: true, ...(principal ? { principal } : {}) };
package/lib/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type HttpRequestInfo, type HttpRouter, type HttpRouterOptions, type HttpRouteRegistration, type RouteContext, type WsConnection, type WsHandle, type WsRouteOptions, type WsTransport, type WsUpgradeDecision, type WsUpgradeRequest, type ListedWsRoute, } from './http-host.js';
2
2
  export type { HttpHostAddress, HttpHostOptions, HttpHostTlsOptions } from './host-options.js';
3
3
  export { listHttpHostAddresses } from './addresses.js';
4
+ export { DEFAULT_HTTP_PORT, ENVIRONMENT_REFERENCE_PATTERN, InvalidHttpPortError, describeInvalidPortValue, parseHttpPort, } from './port.js';
4
5
  export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, type AuthenticatedTokenPrincipal, type ConsoleAccess, type AuthScope, type TokenRegistryConfig, } from './token-registry.js';
5
- export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, type RpcAuditOptions, type ConsoleAccessProvider, type ConsoleAuditEntry, type ConsoleBootstrapPrincipal, type ConsoleHttpDecision, type ConsolePrincipal, type ConsoleRequestMeta, type ConsoleRpcDecision, type ConsoleUserPrincipal, } from './access-control.js';
6
+ export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, grantsDenialReason, isAuditedRpcDenial, unknownConsoleRpcError, webSocketDenialReason, type AuthorizeConsoleRpcOptions, isUserPrincipal, isExplicitConsolePrincipal, isGlobalOwnerPrincipal, isLocalSocketPrincipal, LOCAL_SOCKET_CREDENTIAL_ID, LOCAL_SOCKET_USER_ID, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, type RpcAuditOptions, type ConsoleAccessProvider, type ConsoleAuditEntry, type ConsoleBootstrapPrincipal, type ConsoleHttpDecision, type ConsolePrincipal, type ConsoleRequestMeta, type ConsoleRpcDecision, type ConsoleUserPrincipal, } from './access-control.js';
6
7
  export { timingSafeEqualString } from './timing-safe-equal.js';
7
8
  export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken, type ProtocolAuthRequest } from './protocol-auth.js';
8
9
  export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, type TrustProxyPredicate, type TrustProxySetting, } from './trust-proxy.js';
@@ -10,9 +11,11 @@ export { createConsoleEventHub, consoleEventHubToken, type ConsoleEventHub, } fr
10
11
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
11
12
  export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, type HttpBodyReadOptions, } from './json-body.js';
12
13
  export { createResponseStream, emptyResponse, jsonResponse, SSE_HEADERS, type HttpResponseStream, type HttpResponseStreamOptions, } from '@zhin.js/host-http-contract';
13
- export { dispatchRuntimeConsoleRpc, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type PluginInstallPlan, type PluginUninstallPlan, type PluginUpdatePlan, type PluginManagementPort, type PluginManagementState, type PluginVerificationResult, type PluginVerificationPlan, type PluginConfigValidation, } from './console-rpc.js';
14
+ export { isRuntimeConsoleRpcType, RUNTIME_CONSOLE_RPC_TYPES } from './console-rpc-types.js';
15
+ export { createUnknownRpcLog, unknownRpcLog, type UnknownRpcLog, type UnknownRpcLogEntry } from './unknown-rpc.js';
16
+ export { dispatchRuntimeConsoleRpc, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type PluginInstallPlan, type PluginUninstallPlan, type PluginUpdatePlan, type PluginManagementPort, type PluginManagementState, type PluginInspectOptions, type PluginDependencyCheck, type PluginVerificationResult, type PluginVerificationPlan, type PluginConfigValidation, } from './console-rpc.js';
14
17
  export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
15
18
  export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
16
19
  export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
17
- export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, type ConsoleLoginAssistPort, type ConsoleEndpointLoginAccess, loginAuditDetail, LOGIN_SUBMIT_WAIT_MS, } from './console-rpc-extended/index.js';
20
+ export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleScheduleTarget, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, type ConsoleLoginAssistPort, type ConsoleEndpointLoginAccess, loginAuditDetail, LOGIN_SUBMIT_WAIT_MS, } from './console-rpc-extended/index.js';
18
21
  export * from './host-installer.js';
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  export { createHttpRouter, httpHostToken, normalizePath, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
2
2
  export { listHttpHostAddresses } from './addresses.js';
3
+ export { DEFAULT_HTTP_PORT, ENVIRONMENT_REFERENCE_PATTERN, InvalidHttpPortError, describeInvalidPortValue, parseHttpPort, } from './port.js';
3
4
  export { TokenRegistry, extractBearerToken, publicConsoleAccess, unrestrictedConsoleAccess, } from './token-registry.js';
4
- export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, isUserPrincipal, isExplicitConsolePrincipal, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, } from './access-control.js';
5
+ export { auditActor, authorizeConsoleHttp, authorizeConsoleRpc, authorizeConsoleWebSocket, globalPermissions, grantsDenialReason, isAuditedRpcDenial, unknownConsoleRpcError, webSocketDenialReason, isUserPrincipal, isExplicitConsolePrincipal, isGlobalOwnerPrincipal, isLocalSocketPrincipal, LOCAL_SOCKET_CREDENTIAL_ID, LOCAL_SOCKET_USER_ID, pluginRpcWriteAllowed, principalAllows, requestMeta, rpcAuditDetail, rpcAuditTarget, } from './access-control.js';
5
6
  export { timingSafeEqualString } from './timing-safe-equal.js';
6
7
  export { isLoopbackAddress, isLoopbackHost, verifyProtocolAccessToken } from './protocol-auth.js';
7
8
  export { compileTrustProxy, hasForwardingHeaders, parseTrustProxy, resolveClientAddress, TrustProxyConfigError, } from './trust-proxy.js';
@@ -9,6 +10,8 @@ export { createConsoleEventHub, consoleEventHubToken, } from './console-events.j
9
10
  export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, } from './openapi.js';
10
11
  export { HttpBodyError, readBodyBytes, readJsonBody, readTextBody, } from './json-body.js';
11
12
  export { createResponseStream, emptyResponse, jsonResponse, SSE_HEADERS, } from '@zhin.js/host-http-contract';
13
+ export { isRuntimeConsoleRpcType, RUNTIME_CONSOLE_RPC_TYPES } from './console-rpc-types.js';
14
+ export { createUnknownRpcLog, unknownRpcLog } from './unknown-rpc.js';
12
15
  export { dispatchRuntimeConsoleRpc, pickRpcReply, } from './console-rpc.js';
13
16
  export { dispatchPluginConsoleRpc, PLUGIN_SWITCH_REQUIRES_GATEWAY } from './console-plugin-rpc.js';
14
17
  export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, } from './project-files.js';