@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
@@ -1,4 +1,4 @@
1
- import { type ConsoleGrant, type ConsolePermission, type ConsolePluginPermissionInfo, type ConsolePluginPermissionLevelResolver, type ConsoleRpcPermissionRule, type ConsoleScopeTarget } from '@zhin.js/console-protocol';
1
+ import { CONSOLE_UNKNOWN_RPC_CODE, type ConsoleDenialReason, type ConsoleGrant, type ConsolePermission, type ConsolePluginPermissionInfo, type ConsolePluginPermissionLevelResolver, type ConsoleRpcPermissionRule, type ConsoleScopeTarget } from '@zhin.js/console-protocol';
2
2
  /**
3
3
  * Console principals (ADR 0049 §1):
4
4
  * - `bootstrap`: the `http.token` before the first owner exists (`open: false`), or a Host
@@ -58,6 +58,16 @@ export interface ConsoleAccessProvider {
58
58
  audit?(entry: ConsoleAuditEntry): void;
59
59
  }
60
60
  export declare function isUserPrincipal(principal: ConsolePrincipal | undefined): principal is ConsoleUserPrincipal;
61
+ /** User id and credential id of the gateway control-socket principal (ADR 0054 §6). */
62
+ export declare const LOCAL_SOCKET_USER_ID = "local";
63
+ export declare const LOCAL_SOCKET_CREDENTIAL_ID = "local-socket";
64
+ /**
65
+ * The principal of a request from the project owner's CLI over `.zhin/gateway.sock` (no token).
66
+ * Sessions and API tokens never carry this credential id (theirs are token hashes / token ids).
67
+ */
68
+ export declare function isLocalSocketPrincipal(principal: ConsolePrincipal | undefined): boolean;
69
+ /** A user principal with a global `owner` binding (the local socket principal is one). */
70
+ export declare function isGlobalOwnerPrincipal(principal: ConsolePrincipal | undefined): boolean;
61
71
  /**
62
72
  * Whether the request carried an explicit credential: a user session / API token, or the
63
73
  * still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
@@ -93,12 +103,36 @@ export type ConsoleRpcDecision = Readonly<{
93
103
  delegated?: true;
94
104
  }> | Readonly<{
95
105
  allowed: false;
96
- code: 'setup_required' | 'unmapped' | 'forbidden';
106
+ /**
107
+ * `unknown_rpc`: the Host has no handler and no permission entry for the type (zhinjs/zhin#1250).
108
+ * Nothing runs; it is not a security refusal and callers do not audit it (see
109
+ * {@link isAuditedRpcDenial}). Every other code is a refusal that is audited.
110
+ */
111
+ code: 'setup_required' | 'unmapped' | 'forbidden' | typeof CONSOLE_UNKNOWN_RPC_CODE;
97
112
  error: string;
98
113
  permission: ConsolePermission | null;
114
+ /** Audit `detail.reason`; absent for `unknown_rpc`. */
115
+ reason?: ConsoleDenialReason;
99
116
  }>;
117
+ export interface AuthorizeConsoleRpcOptions {
118
+ /**
119
+ * Whether this Host has a handler for an RPC type that is missing from the permission map. True
120
+ * (or no predicate) keeps the audited `unmapped` refusal — a handler without a permission entry
121
+ * is a development error; false answers `unknown_rpc`. Mapped types never consult it.
122
+ */
123
+ readonly registered?: (type: string) => boolean;
124
+ }
125
+ /** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
126
+ export declare function isAuditedRpcDenial(decision: ConsoleRpcDecision): boolean;
127
+ /** Error text of an unknown RPC: the (truncated) type and nothing else. */
128
+ export declare function unknownConsoleRpcError(type: unknown): string;
100
129
  /** RPC authorization for one principal and message (deny by default). */
101
- export declare function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>): ConsoleRpcDecision;
130
+ export declare function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>, options?: AuthorizeConsoleRpcOptions): ConsoleRpcDecision;
131
+ /**
132
+ * Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
133
+ * adapter / endpoint, else `insufficient_permission`.
134
+ */
135
+ export declare function grantsDenialReason(grants: readonly ConsoleGrant[], permission: string): ConsoleDenialReason;
102
136
  /**
103
137
  * For plugin RPC: whether the principal holds the rule's write permission (`feature.configure`,
104
138
  * or `endpoint.manage` for adapter account methods) at its target. The plugin RPC dispatcher
@@ -114,6 +148,8 @@ export type ConsoleHttpDecision = Readonly<{
114
148
  code: 'unauthorized' | 'forbidden' | 'setup_required';
115
149
  error: string;
116
150
  permission: string | null;
151
+ /** Audit `detail.reason`. */
152
+ reason: ConsoleDenialReason;
117
153
  }>;
118
154
  /**
119
155
  * HTTP authorization of a route under the API base. `principal` is undefined when no valid
@@ -121,6 +157,8 @@ export type ConsoleHttpDecision = Readonly<{
121
157
  */
122
158
  export declare function authorizeConsoleHttp(principal: ConsolePrincipal | undefined, method: string, relativePath: string): ConsoleHttpDecision;
123
159
  /** WebSocket upgrade authorization for non-bootstrap principals. */
160
+ /** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
161
+ export declare function webSocketDenialReason(principal: ConsolePrincipal, pathname: string): ConsoleDenialReason;
124
162
  export declare function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname: string): boolean;
125
163
  /**
126
164
  * JSON length of the detail of one audit entry. The gateway stores at most 4096 characters of
@@ -1,8 +1,24 @@
1
- import { CONSOLE_PERMISSION_LIST, CONSOLE_WRITE_PERMISSIONS, expandPluginPermissions, grantsAllow, isPluginConsoleRpcType, resolveConsoleHttpPermission, resolveConsoleRpcPermission, resolveConsoleWsPermission, } from '@zhin.js/console-protocol';
1
+ import { CONSOLE_DENIAL_REASONS, CONSOLE_PERMISSION_LIST, CONSOLE_UNKNOWN_RPC_CODE, CONSOLE_WRITE_PERMISSIONS, expandPluginPermissions, grantsAllow, grantsAllowAlsoRequired, isPluginConsoleRpcType, resolveConsoleHttpPermission, resolveConsoleRpcPermission, resolveConsoleWsPermission, } from '@zhin.js/console-protocol';
2
2
  import { hasForwardingHeaders } from './trust-proxy.js';
3
3
  export function isUserPrincipal(principal) {
4
4
  return principal?.kind === 'user' || principal?.kind === 'api-token';
5
5
  }
6
+ /** User id and credential id of the gateway control-socket principal (ADR 0054 §6). */
7
+ export const LOCAL_SOCKET_USER_ID = 'local';
8
+ export const LOCAL_SOCKET_CREDENTIAL_ID = 'local-socket';
9
+ /**
10
+ * The principal of a request from the project owner's CLI over `.zhin/gateway.sock` (no token).
11
+ * Sessions and API tokens never carry this credential id (theirs are token hashes / token ids).
12
+ */
13
+ export function isLocalSocketPrincipal(principal) {
14
+ return principal?.kind === 'user' && principal.userId === LOCAL_SOCKET_USER_ID
15
+ && principal.credentialId === LOCAL_SOCKET_CREDENTIAL_ID;
16
+ }
17
+ /** A user principal with a global `owner` binding (the local socket principal is one). */
18
+ export function isGlobalOwnerPrincipal(principal) {
19
+ return isUserPrincipal(principal)
20
+ && principal.grants.some((grant) => grant.role === 'owner' && grant.scope.kind === 'global');
21
+ }
6
22
  /**
7
23
  * Whether the request carried an explicit credential: a user session / API token, or the
8
24
  * still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
@@ -55,27 +71,67 @@ export function globalPermissions(principal, pluginPermissions = []) {
55
71
  }
56
72
  return expandPluginPermissions([...held], pluginPermissions);
57
73
  }
74
+ /** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
75
+ export function isAuditedRpcDenial(decision) {
76
+ return !decision.allowed && decision.code !== CONSOLE_UNKNOWN_RPC_CODE;
77
+ }
78
+ /** Error text of an unknown RPC: the (truncated) type and nothing else. */
79
+ export function unknownConsoleRpcError(type) {
80
+ return `RPC "${String(type ?? '').slice(0, 64)}" 不存在`;
81
+ }
58
82
  /** RPC authorization for one principal and message (deny by default). */
59
- export function authorizeConsoleRpc(principal, message) {
83
+ export function authorizeConsoleRpc(principal, message, options = {}) {
60
84
  const rule = resolveConsoleRpcPermission(message.type, message);
61
85
  const permission = rule ? (rule.kind === 'exact' ? rule.permission : rule.read) : null;
62
86
  if (principal.kind === 'bootstrap') {
63
87
  if (principal.open) {
64
88
  return Object.freeze({ allowed: true, rule, permission, write: rule ? isWriteRule(rule) : true });
65
89
  }
66
- return Object.freeze({ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission });
90
+ return Object.freeze({
91
+ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission,
92
+ reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED,
93
+ });
67
94
  }
68
95
  if (!rule) {
69
- return Object.freeze({ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null });
96
+ const type = typeof message.type === 'string' ? message.type : '';
97
+ if (options.registered && !options.registered(type)) {
98
+ return Object.freeze({ allowed: false, code: CONSOLE_UNKNOWN_RPC_CODE, error: unknownConsoleRpcError(message.type), permission: null });
99
+ }
100
+ return Object.freeze({
101
+ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null,
102
+ reason: CONSOLE_DENIAL_REASONS.UNMAPPED_PERMISSION,
103
+ });
70
104
  }
71
105
  if (isPluginConsoleRpcType(message.type)) {
72
106
  return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule), delegated: true });
73
107
  }
74
108
  if (!grantsAllow(principal.grants, permission, rule.target)) {
75
- return Object.freeze({ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission });
109
+ return Object.freeze({
110
+ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission,
111
+ reason: grantsDenialReason(principal.grants, permission),
112
+ });
113
+ }
114
+ // `alsoRequires` is held globally (same semantics as grantsAllowAlsoRequired): find the first missing one for the denial reason.
115
+ const missing = rule.kind === 'exact' && !grantsAllowAlsoRequired(principal.grants, rule)
116
+ ? rule.alsoRequires?.find(extra => !grantsAllowAlsoRequired(principal.grants, { ...rule, alsoRequires: [extra] }))
117
+ : undefined;
118
+ if (missing) {
119
+ return Object.freeze({
120
+ allowed: false, code: 'forbidden', error: `缺少权限 ${missing}`, permission,
121
+ reason: grantsDenialReason(principal.grants, missing),
122
+ });
76
123
  }
77
124
  return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule) });
78
125
  }
126
+ /**
127
+ * Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
128
+ * adapter / endpoint, else `insufficient_permission`.
129
+ */
130
+ export function grantsDenialReason(grants, permission) {
131
+ return grants.some(grant => grant.scope.kind !== 'global' && grant.permissions.includes(permission))
132
+ ? CONSOLE_DENIAL_REASONS.SCOPE_DENIED
133
+ : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
134
+ }
79
135
  function isWriteRule(rule) {
80
136
  return rule.kind === 'exact' ? CONSOLE_WRITE_PERMISSIONS.has(rule.permission) : false;
81
137
  }
@@ -90,7 +146,7 @@ export function pluginRpcWriteAllowed(principal, rule) {
90
146
  if (!rule)
91
147
  return false;
92
148
  const permission = rule.kind === 'exact' ? rule.permission : rule.write;
93
- return grantsAllow(principal.grants, permission, rule.target);
149
+ return grantsAllow(principal.grants, permission, rule.target) && grantsAllowAlsoRequired(principal.grants, rule);
94
150
  }
95
151
  /**
96
152
  * HTTP authorization of a route under the API base. `principal` is undefined when no valid
@@ -102,15 +158,15 @@ export function authorizeConsoleHttp(principal, method, relativePath) {
102
158
  if (rule?.permission === 'public')
103
159
  return Object.freeze({ allowed: true, permission });
104
160
  if (!principal) {
105
- return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission });
161
+ return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission, reason: CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED });
106
162
  }
107
163
  if (principal.kind === 'bootstrap') {
108
164
  if (principal.open || rule?.permission === 'bootstrap')
109
165
  return Object.freeze({ allowed: true, permission });
110
- return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission });
166
+ return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission, reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED });
111
167
  }
112
168
  if (!rule) {
113
- return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null });
169
+ return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null, reason: CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE });
114
170
  }
115
171
  if (rule.permission === 'rpc' || rule.permission === 'authenticated' || rule.permission === 'bootstrap') {
116
172
  return Object.freeze({ allowed: true, permission });
@@ -118,9 +174,20 @@ export function authorizeConsoleHttp(principal, method, relativePath) {
118
174
  // Scoped permissions over plain HTTP have no knowable target: a global binding is required.
119
175
  if (grantsAllow(principal.grants, rule.permission))
120
176
  return Object.freeze({ allowed: true, permission });
121
- return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission });
177
+ return Object.freeze({
178
+ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission,
179
+ reason: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION,
180
+ });
122
181
  }
123
182
  /** WebSocket upgrade authorization for non-bootstrap principals. */
183
+ /** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
184
+ export function webSocketDenialReason(principal, pathname) {
185
+ if (principal.kind === 'bootstrap')
186
+ return CONSOLE_DENIAL_REASONS.SETUP_REQUIRED;
187
+ return resolveConsoleWsPermission(pathname) === null
188
+ ? CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE
189
+ : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
190
+ }
124
191
  export function authorizeConsoleWebSocket(principal, pathname) {
125
192
  // Only the Console's own sockets are host-authenticated; plugin sockets use `auth: 'protocol'`.
126
193
  const permission = resolveConsoleWsPermission(pathname);
@@ -202,8 +269,43 @@ export function rpcAuditDetail(message, reply, options) {
202
269
  type: String(message.type ?? '').slice(0, 256),
203
270
  keys: Object.freeze([...new Set([...keys, ...data])].slice(0, 64).map(key => key.slice(0, 128))),
204
271
  ...databaseStatementDetail(message, reply, options),
272
+ ...envDetail(message),
205
273
  }));
206
274
  }
275
+ const auditName = (value, max) => typeof value === 'string' && value ? value.slice(0, max) : undefined;
276
+ /**
277
+ * Which dotenv file and variable names an `env:reveal` / `env:save` touched (issue #1250). Never a
278
+ * value: `changes[].value` and `content` are not read.
279
+ */
280
+ function envDetail(message) {
281
+ if (message.type !== 'env:reveal' && message.type !== 'env:save')
282
+ return {};
283
+ const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data) ? message.data : {};
284
+ const pick = (key) => message[key] ?? data[key];
285
+ const file = auditName(pick('filename'), 64);
286
+ if (message.type === 'env:reveal') {
287
+ const key = auditName(pick('key'), 128);
288
+ return { ...(file ? { file } : {}), ...(key ? { key } : {}) };
289
+ }
290
+ const changes = Array.isArray(pick('changes')) ? pick('changes') : undefined;
291
+ if (!changes)
292
+ return { ...(file ? { file } : {}), ...(pick('content') !== undefined ? { mode: 'replace' } : {}) };
293
+ const set = [];
294
+ const deleted = [];
295
+ for (const change of changes.slice(0, 64)) {
296
+ if (!change || typeof change !== 'object')
297
+ continue;
298
+ const key = auditName(change.key, 128);
299
+ if (key)
300
+ (change.delete === true ? deleted : set).push(key);
301
+ }
302
+ return {
303
+ ...(file ? { file } : {}),
304
+ set: Object.freeze(set),
305
+ deleted: Object.freeze(deleted),
306
+ ...(changes.length > 64 ? { truncated: true } : {}),
307
+ };
308
+ }
207
309
  export function rpcAuditTarget(rule) {
208
310
  if (!rule)
209
311
  return undefined;
@@ -15,7 +15,13 @@ const INTROSPECTION_PAGE_SIZES = {
15
15
  endpoints: 30,
16
16
  bindings: 30,
17
17
  mcp: 30,
18
+ 'recent-turns': 20,
18
19
  };
20
+ /** Owning plugin of a capability row (`ConsoleIntrospectionOwner`, #1250): searchable by `q`. */
21
+ const OWNER_FIELDS = [
22
+ (item) => stringProp(item, 'pluginInstance'),
23
+ (item) => stringProp(item, 'pluginPackage'),
24
+ ];
19
25
  // ---------------------------------------------------------------------------
20
26
  // Runtime snapshot introspection
21
27
  // ---------------------------------------------------------------------------
@@ -28,6 +34,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
28
34
  (c) => stringProp(c, 'pattern'),
29
35
  (c) => stringProp(c, 'desc'),
30
36
  (c) => stringProp(c, 'plugin'),
37
+ ...OWNER_FIELDS,
31
38
  ],
32
39
  },
33
40
  {
@@ -39,6 +46,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
39
46
  (m) => stringProp(m, 'phase'),
40
47
  (m) => stringProp(m, 'target'),
41
48
  (m) => stringProp(m, 'source'),
49
+ ...OWNER_FIELDS,
42
50
  ],
43
51
  },
44
52
  {
@@ -48,6 +56,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
48
56
  (c) => stringProp(c, 'name'),
49
57
  (c) => stringProp(c, 'owner'),
50
58
  (c) => stringProp(c, 'source'),
59
+ ...OWNER_FIELDS,
51
60
  ],
52
61
  },
53
62
  {
@@ -64,11 +73,22 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
64
73
  },
65
74
  {
66
75
  kind: 'bindings',
67
- collect: (value) => ({ rows: value?.bindings?.() ?? [] }),
76
+ collect: async (value) => ({ rows: await value?.bindings?.() ?? [] }),
68
77
  fields: [
69
78
  (a) => stringProp(a, 'name'),
70
79
  (a) => stringProp(a, 'provider'),
71
80
  (a) => stringProp(a, 'model'),
81
+ (a) => stringProp(a, 'displayName'),
82
+ ],
83
+ },
84
+ {
85
+ // #1250: the Agent workbench's recent conversations come from the turn trace archive, newest first.
86
+ kind: 'recent-turns',
87
+ collect: async (value) => ({ rows: await value?.recentTurns?.() ?? [] }),
88
+ fields: [
89
+ (t) => stringProp(t, 'sessionDisplayName'),
90
+ (t) => stringProp(t, 'sessionKey'),
91
+ (t) => stringProp(t, 'status'),
72
92
  ],
73
93
  },
74
94
  {
@@ -78,6 +98,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
78
98
  (t) => stringProp(t, 'name'),
79
99
  (t) => stringProp(t, 'source'),
80
100
  (t) => stringProp(t, 'description'),
101
+ ...OWNER_FIELDS,
81
102
  ],
82
103
  },
83
104
  {
@@ -91,6 +112,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
91
112
  (section) => stringProp(section, 'layer'),
92
113
  (section) => stringProp(section, 'retention'),
93
114
  (section) => stringProp(section, 'source'),
115
+ ...OWNER_FIELDS,
94
116
  ],
95
117
  },
96
118
  {
@@ -113,7 +135,7 @@ export function registerIntrospectionRoutes(route, base, ctx, options) {
113
135
  const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
114
136
  try {
115
137
  const introspection = lease?.value.introspection;
116
- const { rows, note } = collect(introspection);
138
+ const { rows, note } = await collect(introspection);
117
139
  const query = url.searchParams;
118
140
  const filter = query.get('filter')?.trim() || undefined;
119
141
  const filtered = filterByFields([...rows], filter, fields);
@@ -35,7 +35,10 @@ export interface ConsoleAgentIntrospection {
35
35
  commands?(): readonly unknown[];
36
36
  middlewares?(): readonly unknown[];
37
37
  components?(): readonly unknown[];
38
- bindings?(): readonly unknown[];
38
+ /** Agent bindings with the provider / model they actually run on (may read the provider graph). */
39
+ bindings?(): readonly unknown[] | Promise<readonly unknown[]>;
40
+ /** Recent Agent turns from the trace archive, newest first (#1250). */
41
+ recentTurns?(): readonly unknown[] | Promise<readonly unknown[]>;
39
42
  tools?(): readonly unknown[];
40
43
  promptSections?(): readonly unknown[];
41
44
  mcp?(): {
@@ -11,6 +11,7 @@ import { join } from 'node:path';
11
11
  import { readJsonBody } from './json-body.js';
12
12
  import { jsonResponse } from '@zhin.js/host-http-contract';
13
13
  import { MarketplaceDetailCache, MarketplaceRegistry, isValidNpmPackageName, } from './marketplace-registry.js';
14
+ import { informativeKeywords, isLocale, normalizePluginCategory, packageAuthorName, pickLocalizedText, readPluginMarketplaceMetadata, } from '@zhin.js/plugin-runtime';
14
15
  import { clampPositiveInt, decodePathParam, logInternalError, normalizeBase, parsePositiveInt, registerAgentSessionRoutes, registerIntrospectionRoutes, requireWriteScope, trimTrailingSlashes, } from './console-rest-pages-helpers.js';
15
16
  const PLUGINS_CACHE_TTL = 5 * 60 * 1000;
16
17
  const PACKAGE_DETAIL_CACHE_TTL = 5 * 60 * 1000;
@@ -202,6 +203,10 @@ function registerLogsRoutes(route, base, ctx) {
202
203
  });
203
204
  }, { summary: 'Cleanup old system logs', tags: ['console', 'logs'] });
204
205
  }
206
+ /** Locale-keyed texts from an untrusted registry entry (reuses the published-manifest reader). */
207
+ function localizedTextOf(value) {
208
+ return readPluginMarketplaceMetadata({ metadata: { displayNameI18n: value } }).displayNameI18n;
209
+ }
205
210
  // ---------------------------------------------------------------------------
206
211
  // marketplace(legacy marketplace-rest-api.ts;plugins.json + npmmirror)
207
212
  // ---------------------------------------------------------------------------
@@ -225,20 +230,25 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
225
230
  const versions = meta.versions;
226
231
  const latestInfo = latest && versions ? versions[latest] : undefined;
227
232
  const time = meta.time;
233
+ // Marketplace fields come from the published manifest (`zhin.metadata`); the author is the
234
+ // package's author / maintainers, never the publishing account (`_npmUser`, a release bot).
235
+ const market = readPluginMarketplaceMetadata(latestInfo?.zhin);
228
236
  return {
229
237
  name: meta.name,
230
238
  version: latest,
231
- description: meta.description || '',
239
+ ...(market.displayName ? { displayName: market.displayName } : {}),
240
+ ...(market.displayNameI18n ? { displayNameI18n: market.displayNameI18n } : {}),
241
+ ...(market.descriptionI18n ? { descriptionI18n: market.descriptionI18n } : {}),
242
+ ...(market.category ? { category: market.category } : {}),
243
+ description: latestInfo?.description || meta.description || '',
232
244
  readme: meta.readme || '',
233
245
  license: meta.license || latestInfo?.license || '',
234
246
  homepage: meta.homepage || latestInfo?.homepage || '',
235
247
  repository: meta.repository?.url ||
236
248
  latestInfo?.repository?.url ||
237
249
  '',
238
- author: typeof meta.author === 'string'
239
- ? meta.author
240
- : meta.author?.name || '',
241
- keywords: latestInfo?.keywords || [],
250
+ author: packageAuthorName(latestInfo ?? {}) ?? packageAuthorName(meta) ?? '',
251
+ keywords: informativeKeywords(latestInfo?.keywords),
242
252
  engines: latestInfo?.engines || {},
243
253
  peerDependencies: latestInfo?.peerDependencies || {},
244
254
  versions: Object.keys(versions || {}),
@@ -250,30 +260,46 @@ function registerMarketplaceRoutes(route, base, ctx, fetchFn, pluginRegistryUrl,
250
260
  const q = url.searchParams.get('q') ?? url.searchParams.get('keyword') ?? '';
251
261
  const pageNum = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1);
252
262
  const pageSize = Math.min(100, Math.max(1, parseInt(url.searchParams.get('size') ?? url.searchParams.get('limit') ?? '20', 10) || 20));
253
- const category = url.searchParams.get('category') ?? undefined;
263
+ const rawCategory = url.searchParams.get('category') || undefined;
264
+ const category = rawCategory === undefined ? undefined : normalizePluginCategory(rawCategory) ?? rawCategory;
254
265
  const official = url.searchParams.get('official') ?? undefined;
266
+ const rawLocale = url.searchParams.get('locale');
267
+ const locale = isLocale(rawLocale) ? rawLocale : undefined;
255
268
  const searchKeyword = q.trim().toLowerCase();
256
269
  try {
257
270
  const allPlugins = (await registry.list());
258
- let plugins = allPlugins.map((p) => ({
259
- name: p.name,
260
- displayName: p.displayName || '',
261
- version: p.version || '',
262
- description: p.description || '',
263
- author: p.author || '',
264
- isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
265
- official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
266
- category: p.category || 'util',
267
- keywords: p.tags || [],
268
- npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
269
- date: p.lastUpdate || '',
270
- downloads: p.downloads || { weekly: 0, monthly: 0 },
271
- readme: p.readme || '',
272
- license: p.license || '',
273
- }));
271
+ let plugins = allPlugins.map((p) => {
272
+ const displayNameI18n = localizedTextOf(p.displayNameI18n);
273
+ const descriptionI18n = localizedTextOf(p.descriptionI18n);
274
+ const displayName = typeof p.displayName === 'string' ? p.displayName : '';
275
+ const description = typeof p.description === 'string' ? p.description : '';
276
+ return {
277
+ name: p.name,
278
+ // `locale` resolves the display texts for the caller; translations stay available either way.
279
+ displayName: locale ? pickLocalizedText(displayNameI18n, locale, displayName) ?? '' : displayName,
280
+ ...(displayNameI18n ? { displayNameI18n } : {}),
281
+ version: p.version || '',
282
+ description: locale ? pickLocalizedText(descriptionI18n, locale, description) ?? '' : description,
283
+ ...(descriptionI18n ? { descriptionI18n } : {}),
284
+ author: p.author || '',
285
+ isOfficial: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
286
+ official: typeof p.name === 'string' && /^@zhin\.js\/[a-z0-9][a-z0-9._-]*$/.test(p.name),
287
+ category: normalizePluginCategory(p.category) ?? 'utility',
288
+ keywords: informativeKeywords(p.tags),
289
+ npm: p.npm || `https://www.npmjs.com/package/${p.name}`,
290
+ date: p.lastUpdate || '',
291
+ downloads: p.downloads || { weekly: 0, monthly: 0 },
292
+ readme: p.readme || '',
293
+ license: p.license || '',
294
+ };
295
+ });
274
296
  if (searchKeyword) {
275
297
  plugins = plugins.filter((p) => {
276
- const haystack = [p.name, p.displayName, p.description, ...(p.keywords || [])]
298
+ const haystack = [
299
+ p.name, p.displayName, p.description,
300
+ ...Object.values(p.displayNameI18n ?? {}), ...Object.values(p.descriptionI18n ?? {}),
301
+ ...p.keywords,
302
+ ]
277
303
  .join(' ')
278
304
  .toLowerCase();
279
305
  return haystack.includes(searchKeyword);
@@ -21,6 +21,11 @@ export interface ConsoleRpcExtendedCtx {
21
21
  };
22
22
  /** Narrow generation-bound persistent-job port; null when Assistant is disabled. */
23
23
  resolveScheduleEngine?: () => ConsoleScheduleEngine | null | undefined;
24
+ /**
25
+ * Resolves a persistent job's delivery target (#1250): `{ resolved, display?, reason? }`, where
26
+ * `resolved: false` means the job's results cannot be delivered (no target / unknown account).
27
+ */
28
+ describeScheduleTarget?: (notify: unknown) => ConsoleScheduleTarget;
24
29
  /** LoginAssist list/submit/cancel(刷新后仍可消费扫码/滑块待办);ADR 0058 flow methods are optional. */
25
30
  loginAssist?: ConsoleLoginAssistPort;
26
31
  /** ADR 0058: the Endpoint's login declaration and lease-scoped login control (`null` = unknown Endpoint). */
@@ -182,6 +187,16 @@ export interface ScheduleJobRow {
182
187
  id: string;
183
188
  cron: string;
184
189
  description?: string;
190
+ /** Host display metadata (`ScheduleJobDisplay` in `@zhin.js/plugin-runtime`). */
191
+ title?: string;
192
+ titleI18n?: Readonly<Partial<Record<string, string>>>;
193
+ kind?: 'plugin-internal' | 'user-task';
194
+ lastRun?: {
195
+ startedAt: number;
196
+ finishedAt: number;
197
+ status: 'succeeded' | 'failed';
198
+ error?: string;
199
+ };
185
200
  }
186
201
  export interface InboxSelection {
187
202
  where(query: Record<string, unknown>): InboxSelection;
@@ -264,4 +279,11 @@ export interface EndpointManagementPort {
264
279
  setGroupAdmin?(request: EndpointAdminCommand): Promise<void>;
265
280
  deleteFriend?(request: EndpointUserCommand): Promise<void>;
266
281
  }
282
+ /** `target` of a `schedule:list` persistent row (#1250). */
283
+ export interface ConsoleScheduleTarget {
284
+ readonly resolved: boolean;
285
+ readonly display?: string;
286
+ /** `missing_target` | `incomplete_target` | `endpoint_not_found` */
287
+ readonly reason?: string;
288
+ }
267
289
  export {};
@@ -9,7 +9,7 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
9
9
  switch (type) {
10
10
  case 'schedule:list':
11
11
  case 'cron:list':
12
- return listSchedule(ctx);
12
+ return listSchedule(ctx, d);
13
13
  case 'cron:validate':
14
14
  return validateCron(d);
15
15
  case 'cron:add':
@@ -8,7 +8,7 @@ export async function listFriends(d, ctx) {
8
8
  const friends = [...await management.listFriends()];
9
9
  return { data: { friends, count: friends.length } };
10
10
  }
11
- return { error: `当前适配器(${endpoint.adapter})不支持好友列表` };
11
+ return unsupportedList('friends', 'listFriends', endpoint.adapter, '好友列表');
12
12
  });
13
13
  }
14
14
  export async function listGroups(d, ctx) {
@@ -20,7 +20,7 @@ export async function listGroups(d, ctx) {
20
20
  const groups = [...await management.listGroups()];
21
21
  return { data: { groups, count: groups.length } };
22
22
  }
23
- return { error: `当前适配器(${endpoint.adapter})不支持群列表` };
23
+ return unsupportedList('groups', 'listGroups', endpoint.adapter, '群列表');
24
24
  });
25
25
  }
26
26
  export async function listChannels(d, ctx) {
@@ -32,7 +32,7 @@ export async function listChannels(d, ctx) {
32
32
  const channels = [...await management.listChannels()];
33
33
  return { data: { channels, count: channels.length } };
34
34
  }
35
- return { error: `当前适配器(${endpoint.adapter})不支持频道列表` };
35
+ return unsupportedList('channels', 'listChannels', endpoint.adapter, '频道列表');
36
36
  });
37
37
  }
38
38
  export async function listGroupMembers(d, ctx) {
@@ -45,12 +45,29 @@ export async function listGroupMembers(d, ctx) {
45
45
  return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
46
46
  const method = management.listGroupMembers;
47
47
  if (!method) {
48
- return { error: `当前适配器(${adapter})不支持群成员列表` };
48
+ return unsupportedList('members', 'listGroupMembers', adapter, '群成员列表');
49
49
  }
50
50
  const members = [...await method.call(management, { groupId })];
51
51
  return { data: { members, count: members.length } };
52
52
  });
53
53
  }
54
+ /**
55
+ * A read the Adapter does not declare is not a failure (#1250): reply with an empty list plus
56
+ * `status: 'unsupported'` (`ConsoleEndpointCapabilityUnsupported`) instead of `{ error }`, so the
57
+ * Console can render "该平台不提供" rather than a red read error. Older clients still see an empty
58
+ * list with `count: 0`.
59
+ */
60
+ function unsupportedList(key, capability, adapter, label) {
61
+ return {
62
+ data: {
63
+ [key]: [],
64
+ count: 0,
65
+ status: 'unsupported',
66
+ capability,
67
+ message: `当前适配器(${adapter})不支持${label}`,
68
+ },
69
+ };
70
+ }
54
71
  export async function groupWriteOp(d, ctx, spec) {
55
72
  const adapter = strField(d, 'adapter');
56
73
  const endpointKey = strField(d, 'endpointKey');
@@ -1,3 +1,3 @@
1
- export type { ConsoleRpcExtendedCtx, ConsoleScheduleEngine, ConsoleWorkroomKnowledgeControlPort, ConsoleWorkroomProfileControlPort, ExtendedRpcResult, ConsoleLoginAssistPort, ConsoleEndpointLoginAccess, } from './contracts.js';
1
+ export type { ConsoleRpcExtendedCtx, ConsoleScheduleEngine, ConsoleScheduleTarget, ConsoleWorkroomKnowledgeControlPort, ConsoleWorkroomProfileControlPort, ExtendedRpcResult, ConsoleLoginAssistPort, ConsoleEndpointLoginAccess, } from './contracts.js';
2
2
  export { loginAuditDetail, LOGIN_SUBMIT_WAIT_MS } from './login-rpc.js';
3
3
  export { dispatchExtendedConsoleRpc } from './dispatcher.js';
@@ -1,5 +1,5 @@
1
1
  import type { ConsoleRpcExtendedCtx, ExtendedRpcResult } from './contracts.js';
2
- export declare function listSchedule(ctx: ConsoleScheduleListCtx): Promise<ExtendedRpcResult> | ExtendedRpcResult;
2
+ export declare function listSchedule(ctx: ConsoleScheduleListCtx, d?: Record<string, unknown>): Promise<ExtendedRpcResult> | ExtendedRpcResult;
3
3
  type ConsoleScheduleListCtx = ConsoleRpcExtendedCtx;
4
4
  export declare function addCron(d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;
5
5
  export declare function mutateCron(type: 'cron:remove' | 'cron:pause' | 'cron:resume', d: Record<string, unknown>, ctx: ConsoleRpcExtendedCtx): Promise<ExtendedRpcResult>;