@zhin.js/host-http 1.2.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/lib/access-control.d.ts +31 -3
  2. package/lib/access-control.js +96 -10
  3. package/lib/console-rest-pages-helpers.js +24 -2
  4. package/lib/console-rest-pages.d.ts +4 -1
  5. package/lib/console-rest-pages.js +49 -23
  6. package/lib/console-rpc-extended/contracts.d.ts +22 -0
  7. package/lib/console-rpc-extended/dispatcher.js +1 -1
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +1 -1
  10. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  11. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  12. package/lib/console-rpc-handlers.d.ts +9 -1
  13. package/lib/console-rpc-handlers.js +97 -10
  14. package/lib/console-rpc-types.d.ts +2 -0
  15. package/lib/console-rpc-types.js +43 -0
  16. package/lib/console-rpc.d.ts +29 -1
  17. package/lib/console-rpc.js +5 -4
  18. package/lib/http-host.js +5 -4
  19. package/lib/index.d.ts +5 -3
  20. package/lib/index.js +3 -1
  21. package/lib/project-files.d.ts +7 -0
  22. package/lib/project-files.js +88 -11
  23. package/lib/unknown-rpc.d.ts +12 -0
  24. package/lib/unknown-rpc.js +33 -0
  25. package/package.json +3 -3
  26. package/src/access-control.ts +132 -11
  27. package/src/console-rest-pages-helpers.ts +27 -3
  28. package/src/console-rest-pages.ts +52 -15
  29. package/src/console-rpc-extended/contracts.ts +18 -0
  30. package/src/console-rpc-extended/dispatcher.ts +1 -1
  31. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  32. package/src/console-rpc-extended/index.ts +1 -0
  33. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  34. package/src/console-rpc-handlers.ts +99 -9
  35. package/src/console-rpc-types.ts +45 -0
  36. package/src/console-rpc.ts +31 -4
  37. package/src/http-host.ts +6 -2
  38. package/src/index.ts +10 -0
  39. package/src/project-files.ts +95 -11
  40. package/src/unknown-rpc.ts +44 -0
@@ -26,7 +26,13 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
26
26
  'plugin.mjs',
27
27
  'plugin.cjs',
28
28
  'schema.json',
29
+ // Agent workspace bootstrap files (`@zhin.js/agent` bootstrap.ts, timer heartbeat) and the
30
+ // conventional knowledge base directory (`ai.knowledge.baseDir`).
29
31
  'AGENTS.md',
32
+ 'SOUL.md',
33
+ 'TOOLS.md',
34
+ 'HEARTBEAT.md',
35
+ 'knowledge',
30
36
  'tsconfig.json',
31
37
  'zhin.config.yml',
32
38
  'zhin.config.yaml',
@@ -34,25 +40,82 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
34
40
  'config.yml',
35
41
  'config.yaml',
36
42
  'config.json',
37
- '.env',
38
- '.env.development',
39
- '.env.production',
40
43
  'README.md',
41
44
  ]);
42
45
  export const FILE_MANAGER_BLOCKED = Object.freeze(new Set([
43
46
  'node_modules',
44
47
  '.git',
45
- '.env.local',
46
48
  'data',
47
49
  'lib',
48
50
  'dist',
49
51
  'coverage',
50
52
  ]));
53
+ /** Dotenv files the environment page (`/ops/env`, `env:*` RPCs) manages. */
54
+ const ENV_FILE_NAMES = Object.freeze(['.env', '.env.development', '.env.production']);
55
+ /** Error text for project-file reads and writes that name a dotenv file (issue #1250). */
56
+ export const DOTENV_ACCESS_DENIED_MESSAGE = 'Access denied: .env files are not available in the project file manager; '
57
+ + 'manage environment variables on the Environment variables page (/ops/env)';
58
+ // Control characters (NUL included) never name a project file and can truncate a path in native code.
59
+ // eslint-disable-next-line no-control-regex
60
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
61
+ /**
62
+ * Strips trailing dots and spaces (what Windows drops from a file name). A linear scan: the
63
+ * equivalent `/[. ]+$/` backtracks quadratically on long runs of spaces (CodeQL js/polynomial-redos).
64
+ */
65
+ function trimTrailingDotsAndSpaces(value) {
66
+ let end = value.length;
67
+ while (end > 0 && (value[end - 1] === '.' || value[end - 1] === ' '))
68
+ end -= 1;
69
+ return value.slice(0, end);
70
+ }
71
+ /**
72
+ * Whether one path segment names a dotenv file (`.env`, `.env.*`), in every spelling a
73
+ * filesystem may resolve to it: any letter case, trailing dots / spaces (stripped by Windows),
74
+ * an NTFS stream suffix (`.env::$DATA`) and percent-encoding.
75
+ */
76
+ function isDotenvSegment(segment) {
77
+ const variants = [segment];
78
+ try {
79
+ const decoded = decodeURIComponent(segment);
80
+ if (decoded !== segment)
81
+ variants.push(decoded);
82
+ }
83
+ catch { /* not percent-encoded */ }
84
+ return variants.some((variant) => {
85
+ const name = trimTrailingDotsAndSpaces(variant.split(':')[0]).toLowerCase();
86
+ return name === '.env' || name.startsWith('.env.');
87
+ });
88
+ }
89
+ /**
90
+ * Whether a project-relative path names a dotenv file or goes through one. Dotenv files hold
91
+ * secrets: the project file manager never lists, reads or writes them (`/ops/env` does).
92
+ */
93
+ export function isDotenvProjectPath(relativePath) {
94
+ return relativePath.replace(/\\/gu, '/').split('/').some(isDotenvSegment);
95
+ }
96
+ /**
97
+ * Whether one path segment names a dot file or dot directory (`.envrc`, `.npmrc`, `.ssh`, …), also
98
+ * percent-encoded. Such files commonly hold credentials; no entry of {@link FILE_MANAGER_ALLOWED}
99
+ * is one, so the project file manager never reads or writes them (the tree already skips them).
100
+ */
101
+ function isDotSegment(segment) {
102
+ const variants = [segment];
103
+ try {
104
+ const decoded = decodeURIComponent(segment);
105
+ if (decoded !== segment)
106
+ variants.push(decoded);
107
+ }
108
+ catch { /* not percent-encoded */ }
109
+ return variants.some(variant => variant.startsWith('.') && variant !== '.');
110
+ }
51
111
  export function isProjectPathAllowed(relativePath) {
52
112
  if (relativePath.includes('..') || path.isAbsolute(relativePath))
53
113
  return false;
114
+ if (CONTROL_CHARACTERS.test(relativePath) || isDotenvProjectPath(relativePath))
115
+ return false;
54
116
  const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
55
- if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment)))
117
+ const segments = normalized.split('/');
118
+ if (segments.some((segment) => FILE_MANAGER_BLOCKED.has(segment) || isDotSegment(segment)))
56
119
  return false;
57
120
  return FILE_MANAGER_ALLOWED.some((allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`));
58
121
  }
@@ -84,6 +147,7 @@ export async function buildProjectFileTree(projectRoot) {
84
147
  return tree.sort(compareTreeNodes);
85
148
  }
86
149
  export async function readProjectFile(projectRoot, relativePath) {
150
+ assertNotDotenv(relativePath);
87
151
  const absPath = await resolveAllowedPath(projectRoot, relativePath);
88
152
  if (!absPath)
89
153
  throw new Error(`Access denied: ${relativePath}`);
@@ -99,6 +163,7 @@ export async function readProjectFile(projectRoot, relativePath) {
99
163
  return { content, size: info.size };
100
164
  }
101
165
  export async function saveProjectFile(projectRoot, relativePath, content) {
166
+ assertNotDotenv(relativePath);
102
167
  const absPath = await resolveAllowedPath(projectRoot, relativePath);
103
168
  if (!absPath)
104
169
  throw new Error(`Access denied: ${relativePath}`);
@@ -109,16 +174,28 @@ export async function saveProjectFile(projectRoot, relativePath, content) {
109
174
  await writeFile(checkedPath, content, 'utf8');
110
175
  }
111
176
  export async function listEnvFiles(projectRoot) {
112
- const names = ['.env', '.env.development', '.env.production'];
113
- const result = await Promise.all(names.map(async (name) => {
114
- const resolved = await resolveAllowedPath(projectRoot, name);
177
+ const result = await Promise.all(ENV_FILE_NAMES.map(async (name) => {
178
+ const resolved = await resolveContainedPath(projectRoot, name, isEnvInventoryTarget);
115
179
  return { name, exists: resolved !== null && await pathExists(resolved) };
116
180
  }));
117
181
  return Object.freeze(result);
118
182
  }
183
+ function assertNotDotenv(relativePath) {
184
+ if (isDotenvProjectPath(relativePath))
185
+ throw new Error(DOTENV_ACCESS_DENIED_MESSAGE);
186
+ }
187
+ /** The env inventory only counts a dotenv file (or link) that stays inside the project and off blocked paths. */
188
+ function isEnvInventoryTarget(relativePath) {
189
+ if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath))
190
+ return false;
191
+ return !relativePath.replace(/\\/gu, '/').split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment));
192
+ }
193
+ function resolveAllowedPath(projectRoot, relativePath) {
194
+ return resolveContainedPath(projectRoot, relativePath, isProjectPathAllowed);
195
+ }
119
196
  /** Resolve existing links, or the nearest existing parent for a new file. */
120
- async function resolveAllowedPath(projectRoot, relativePath) {
121
- if (!isProjectPathAllowed(relativePath))
197
+ async function resolveContainedPath(projectRoot, relativePath, allowed) {
198
+ if (!allowed(relativePath))
122
199
  return null;
123
200
  try {
124
201
  const root = await realpath(projectRoot);
@@ -130,7 +207,7 @@ async function resolveAllowedPath(projectRoot, relativePath) {
130
207
  await lstat(candidate);
131
208
  const resolved = path.resolve(await realpath(candidate), ...missing);
132
209
  const relative = path.relative(root, resolved);
133
- return isProjectPathAllowed(relative) ? resolved : null;
210
+ return allowed(relative) ? resolved : null;
134
211
  }
135
212
  catch (error) {
136
213
  if (error.code !== 'ENOENT')
@@ -0,0 +1,12 @@
1
+ export interface UnknownRpcLog {
2
+ /** Records one unknown RPC; returns true when it was logged (not suppressed). */
3
+ note(type: unknown, now?: number): boolean;
4
+ }
5
+ export type UnknownRpcLogEntry = Readonly<{
6
+ op: 'console_rpc_unknown';
7
+ type: string;
8
+ repeated?: number;
9
+ }>;
10
+ export declare function createUnknownRpcLog(log?: (entry: UnknownRpcLogEntry) => void): UnknownRpcLog;
11
+ /** Process-wide log shared by the Host's RPC routes. */
12
+ export declare const unknownRpcLog: UnknownRpcLog;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Unknown Console RPC (zhinjs/zhin#1250): not a security refusal, so it is never audited. A
3
+ * Console that polls an RPC this Host lacks would otherwise fill the audit log; instead each type
4
+ * is logged at debug level at most once per window, with how often it was seen meanwhile.
5
+ */
6
+ import { formatCompact, getLogger } from '@zhin.js/logger';
7
+ const logger = getLogger('console-rpc');
8
+ const WINDOW_MS = 10 * 60_000;
9
+ const MAX_TYPES = 256;
10
+ export function createUnknownRpcLog(log = entry => logger.debug(formatCompact({ ...entry }))) {
11
+ const seen = new Map();
12
+ return Object.freeze({
13
+ note(type, now = Date.now()) {
14
+ const name = String(type ?? '').slice(0, 64) || '<empty>';
15
+ const entry = seen.get(name);
16
+ if (entry && now - entry.at < WINDOW_MS) {
17
+ entry.suppressed += 1;
18
+ return false;
19
+ }
20
+ if (!entry && seen.size >= MAX_TYPES) {
21
+ const oldest = seen.keys().next().value;
22
+ if (oldest !== undefined)
23
+ seen.delete(oldest);
24
+ }
25
+ seen.delete(name);
26
+ seen.set(name, { at: now, suppressed: 0 });
27
+ log({ op: 'console_rpc_unknown', type: name, ...(entry?.suppressed ? { repeated: entry.suppressed } : {}) });
28
+ return true;
29
+ },
30
+ });
31
+ }
32
+ /** Process-wide log shared by the Host's RPC routes. */
33
+ export const unknownRpcLog = createUnknownRpcLog();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -26,10 +26,10 @@
26
26
  "dependencies": {
27
27
  "ws": "^8.21.1",
28
28
  "@zhin.js/host-http-contract": "1.2.0",
29
+ "@zhin.js/console-protocol": "1.3.0",
29
30
  "@zhin.js/logger": "1.1.3",
30
- "@zhin.js/console-protocol": "1.2.2",
31
31
  "@zhin.js/schedule": "1.1.5",
32
- "@zhin.js/plugin-runtime": "1.2.1"
32
+ "@zhin.js/plugin-runtime": "1.3.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^26.1.2",
@@ -1,12 +1,16 @@
1
1
  import {
2
+ CONSOLE_DENIAL_REASONS,
2
3
  CONSOLE_PERMISSION_LIST,
4
+ CONSOLE_UNKNOWN_RPC_CODE,
3
5
  CONSOLE_WRITE_PERMISSIONS,
4
6
  expandPluginPermissions,
5
7
  grantsAllow,
8
+ grantsAllowAlsoRequired,
6
9
  isPluginConsoleRpcType,
7
10
  resolveConsoleHttpPermission,
8
11
  resolveConsoleRpcPermission,
9
12
  resolveConsoleWsPermission,
13
+ type ConsoleDenialReason,
10
14
  type ConsoleGrant,
11
15
  type ConsolePermission,
12
16
  type ConsolePluginPermissionInfo,
@@ -155,30 +159,98 @@ export type ConsoleRpcDecision =
155
159
  */
156
160
  delegated?: true;
157
161
  }>
158
- | Readonly<{ allowed: false; code: 'setup_required' | 'unmapped' | 'forbidden'; error: string; permission: ConsolePermission | null }>;
162
+ | Readonly<{
163
+ allowed: false;
164
+ /**
165
+ * `unknown_rpc`: the Host has no handler and no permission entry for the type (zhinjs/zhin#1250).
166
+ * Nothing runs; it is not a security refusal and callers do not audit it (see
167
+ * {@link isAuditedRpcDenial}). Every other code is a refusal that is audited.
168
+ */
169
+ code: 'setup_required' | 'unmapped' | 'forbidden' | typeof CONSOLE_UNKNOWN_RPC_CODE;
170
+ error: string;
171
+ permission: ConsolePermission | null;
172
+ /** Audit `detail.reason`; absent for `unknown_rpc`. */
173
+ reason?: ConsoleDenialReason;
174
+ }>;
175
+
176
+ export interface AuthorizeConsoleRpcOptions {
177
+ /**
178
+ * Whether this Host has a handler for an RPC type that is missing from the permission map. True
179
+ * (or no predicate) keeps the audited `unmapped` refusal — a handler without a permission entry
180
+ * is a development error; false answers `unknown_rpc`. Mapped types never consult it.
181
+ */
182
+ readonly registered?: (type: string) => boolean;
183
+ }
184
+
185
+ /** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
186
+ export function isAuditedRpcDenial(decision: ConsoleRpcDecision): boolean {
187
+ return !decision.allowed && decision.code !== CONSOLE_UNKNOWN_RPC_CODE;
188
+ }
189
+
190
+ /** Error text of an unknown RPC: the (truncated) type and nothing else. */
191
+ export function unknownConsoleRpcError(type: unknown): string {
192
+ return `RPC "${String(type ?? '').slice(0, 64)}" 不存在`;
193
+ }
159
194
 
160
195
  /** RPC authorization for one principal and message (deny by default). */
161
- export function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>): ConsoleRpcDecision {
196
+ export function authorizeConsoleRpc(
197
+ principal: ConsolePrincipal,
198
+ message: Readonly<Record<string, unknown>>,
199
+ options: AuthorizeConsoleRpcOptions = {},
200
+ ): ConsoleRpcDecision {
162
201
  const rule = resolveConsoleRpcPermission(message.type, message);
163
202
  const permission = rule ? (rule.kind === 'exact' ? rule.permission : rule.read) : null;
164
203
  if (principal.kind === 'bootstrap') {
165
204
  if (principal.open) {
166
205
  return Object.freeze({ allowed: true, rule, permission, write: rule ? isWriteRule(rule) : true });
167
206
  }
168
- return Object.freeze({ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission });
207
+ return Object.freeze({
208
+ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission,
209
+ reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED,
210
+ });
169
211
  }
170
212
  if (!rule) {
171
- return Object.freeze({ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null });
213
+ const type = typeof message.type === 'string' ? message.type : '';
214
+ if (options.registered && !options.registered(type)) {
215
+ return Object.freeze({ allowed: false, code: CONSOLE_UNKNOWN_RPC_CODE, error: unknownConsoleRpcError(message.type), permission: null });
216
+ }
217
+ return Object.freeze({
218
+ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null,
219
+ reason: CONSOLE_DENIAL_REASONS.UNMAPPED_PERMISSION,
220
+ });
172
221
  }
173
222
  if (isPluginConsoleRpcType(message.type)) {
174
223
  return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule), delegated: true });
175
224
  }
176
225
  if (!grantsAllow(principal.grants, permission!, rule.target)) {
177
- return Object.freeze({ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission });
226
+ return Object.freeze({
227
+ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission,
228
+ reason: grantsDenialReason(principal.grants, permission!),
229
+ });
230
+ }
231
+ // `alsoRequires` is held globally (same semantics as grantsAllowAlsoRequired): find the first missing one for the denial reason.
232
+ const missing = rule.kind === 'exact' && !grantsAllowAlsoRequired(principal.grants, rule)
233
+ ? rule.alsoRequires?.find(extra => !grantsAllowAlsoRequired(principal.grants, { ...rule, alsoRequires: [extra] }))
234
+ : undefined;
235
+ if (missing) {
236
+ return Object.freeze({
237
+ allowed: false, code: 'forbidden', error: `缺少权限 ${missing}`, permission,
238
+ reason: grantsDenialReason(principal.grants, missing),
239
+ });
178
240
  }
179
241
  return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule) });
180
242
  }
181
243
 
244
+ /**
245
+ * Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
246
+ * adapter / endpoint, else `insufficient_permission`.
247
+ */
248
+ export function grantsDenialReason(grants: readonly ConsoleGrant[], permission: string): ConsoleDenialReason {
249
+ return grants.some(grant => grant.scope.kind !== 'global' && grant.permissions.includes(permission))
250
+ ? CONSOLE_DENIAL_REASONS.SCOPE_DENIED
251
+ : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
252
+ }
253
+
182
254
  function isWriteRule(rule: ConsoleRpcPermissionRule): boolean {
183
255
  return rule.kind === 'exact' ? CONSOLE_WRITE_PERMISSIONS.has(rule.permission) : false;
184
256
  }
@@ -192,12 +264,16 @@ export function pluginRpcWriteAllowed(principal: ConsolePrincipal, rule: Console
192
264
  if (principal.kind === 'bootstrap') return principal.open;
193
265
  if (!rule) return false;
194
266
  const permission = rule.kind === 'exact' ? rule.permission : rule.write;
195
- return grantsAllow(principal.grants, permission, rule.target);
267
+ return grantsAllow(principal.grants, permission, rule.target) && grantsAllowAlsoRequired(principal.grants, rule);
196
268
  }
197
269
 
198
270
  export type ConsoleHttpDecision =
199
271
  | Readonly<{ allowed: true; permission: string | null }>
200
- | Readonly<{ allowed: false; status: 401 | 403; code: 'unauthorized' | 'forbidden' | 'setup_required'; error: string; permission: string | null }>;
272
+ | Readonly<{
273
+ allowed: false; status: 401 | 403; code: 'unauthorized' | 'forbidden' | 'setup_required'; error: string; permission: string | null;
274
+ /** Audit `detail.reason`. */
275
+ reason: ConsoleDenialReason;
276
+ }>;
201
277
 
202
278
  /**
203
279
  * HTTP authorization of a route under the API base. `principal` is undefined when no valid
@@ -212,24 +288,35 @@ export function authorizeConsoleHttp(
212
288
  const permission = rule?.permission ?? null;
213
289
  if (rule?.permission === 'public') return Object.freeze({ allowed: true, permission });
214
290
  if (!principal) {
215
- return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission });
291
+ return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission, reason: CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED });
216
292
  }
217
293
  if (principal.kind === 'bootstrap') {
218
294
  if (principal.open || rule?.permission === 'bootstrap') return Object.freeze({ allowed: true, permission });
219
- return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission });
295
+ return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission, reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED });
220
296
  }
221
297
  if (!rule) {
222
- return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null });
298
+ 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 });
223
299
  }
224
300
  if (rule.permission === 'rpc' || rule.permission === 'authenticated' || rule.permission === 'bootstrap') {
225
301
  return Object.freeze({ allowed: true, permission });
226
302
  }
227
303
  // Scoped permissions over plain HTTP have no knowable target: a global binding is required.
228
304
  if (grantsAllow(principal.grants, rule.permission)) return Object.freeze({ allowed: true, permission });
229
- return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission });
305
+ return Object.freeze({
306
+ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission,
307
+ reason: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION,
308
+ });
230
309
  }
231
310
 
232
311
  /** WebSocket upgrade authorization for non-bootstrap principals. */
312
+ /** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
313
+ export function webSocketDenialReason(principal: ConsolePrincipal, pathname: string): ConsoleDenialReason {
314
+ if (principal.kind === 'bootstrap') return CONSOLE_DENIAL_REASONS.SETUP_REQUIRED;
315
+ return resolveConsoleWsPermission(pathname) === null
316
+ ? CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE
317
+ : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
318
+ }
319
+
233
320
  export function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname: string): boolean {
234
321
  // Only the Console's own sockets are host-authenticated; plugin sockets use `auth: 'protocol'`.
235
322
  const permission = resolveConsoleWsPermission(pathname);
@@ -317,9 +404,43 @@ export function rpcAuditDetail(
317
404
  type: String(message.type ?? '').slice(0, 256),
318
405
  keys: Object.freeze([...new Set([...keys, ...data])].slice(0, 64).map(key => key.slice(0, 128))),
319
406
  ...databaseStatementDetail(message, reply, options),
407
+ ...envDetail(message),
320
408
  }));
321
409
  }
322
410
 
411
+ const auditName = (value: unknown, max: number): string | undefined =>
412
+ typeof value === 'string' && value ? value.slice(0, max) : undefined;
413
+
414
+ /**
415
+ * Which dotenv file and variable names an `env:reveal` / `env:save` touched (issue #1250). Never a
416
+ * value: `changes[].value` and `content` are not read.
417
+ */
418
+ function envDetail(message: Readonly<Record<string, unknown>>): Record<string, unknown> {
419
+ if (message.type !== 'env:reveal' && message.type !== 'env:save') return {};
420
+ const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data) ? message.data as Record<string, unknown> : {};
421
+ const pick = (key: string) => message[key] ?? data[key];
422
+ const file = auditName(pick('filename'), 64);
423
+ if (message.type === 'env:reveal') {
424
+ const key = auditName(pick('key'), 128);
425
+ return { ...(file ? { file } : {}), ...(key ? { key } : {}) };
426
+ }
427
+ const changes = Array.isArray(pick('changes')) ? pick('changes') as unknown[] : undefined;
428
+ if (!changes) return { ...(file ? { file } : {}), ...(pick('content') !== undefined ? { mode: 'replace' } : {}) };
429
+ const set: string[] = [];
430
+ const deleted: string[] = [];
431
+ for (const change of changes.slice(0, 64)) {
432
+ if (!change || typeof change !== 'object') continue;
433
+ const key = auditName((change as Record<string, unknown>).key, 128);
434
+ if (key) ((change as Record<string, unknown>).delete === true ? deleted : set).push(key);
435
+ }
436
+ return {
437
+ ...(file ? { file } : {}),
438
+ set: Object.freeze(set),
439
+ deleted: Object.freeze(deleted),
440
+ ...(changes.length > 64 ? { truncated: true } : {}),
441
+ };
442
+ }
443
+
323
444
  export function rpcAuditTarget(rule: ConsoleRpcPermissionRule | null): string | undefined {
324
445
  if (!rule) return undefined;
325
446
  const target = rule.target;
@@ -25,10 +25,17 @@ const INTROSPECTION_PAGE_SIZES = {
25
25
  endpoints: 30,
26
26
  bindings: 30,
27
27
  mcp: 30,
28
+ 'recent-turns': 20,
28
29
  } as const;
29
30
 
30
31
  type IntrospectionKind = keyof typeof INTROSPECTION_PAGE_SIZES;
31
32
 
33
+ /** Owning plugin of a capability row (`ConsoleIntrospectionOwner`, #1250): searchable by `q`. */
34
+ const OWNER_FIELDS: ReadonlyArray<(item: unknown) => string | undefined> = [
35
+ (item) => stringProp(item, 'pluginInstance'),
36
+ (item) => stringProp(item, 'pluginPackage'),
37
+ ];
38
+
32
39
  // ---------------------------------------------------------------------------
33
40
  // Runtime snapshot introspection
34
41
  // ---------------------------------------------------------------------------
@@ -41,7 +48,8 @@ export function registerIntrospectionRoutes(
41
48
  ): void {
42
49
  const collectors: Array<{
43
50
  kind: IntrospectionKind;
44
- collect: (introspection: ConsoleAgentIntrospection | undefined) => { rows: readonly unknown[]; note?: string };
51
+ collect: (introspection: ConsoleAgentIntrospection | undefined) =>
52
+ { rows: readonly unknown[]; note?: string } | Promise<{ rows: readonly unknown[]; note?: string }>;
45
53
  fields: Array<(item: unknown) => string | undefined>;
46
54
  }> = [
47
55
  {
@@ -51,6 +59,7 @@ export function registerIntrospectionRoutes(
51
59
  (c) => stringProp(c, 'pattern'),
52
60
  (c) => stringProp(c, 'desc'),
53
61
  (c) => stringProp(c, 'plugin'),
62
+ ...OWNER_FIELDS,
54
63
  ],
55
64
  },
56
65
  {
@@ -62,6 +71,7 @@ export function registerIntrospectionRoutes(
62
71
  (m) => stringProp(m, 'phase'),
63
72
  (m) => stringProp(m, 'target'),
64
73
  (m) => stringProp(m, 'source'),
74
+ ...OWNER_FIELDS,
65
75
  ],
66
76
  },
67
77
  {
@@ -71,6 +81,7 @@ export function registerIntrospectionRoutes(
71
81
  (c) => stringProp(c, 'name'),
72
82
  (c) => stringProp(c, 'owner'),
73
83
  (c) => stringProp(c, 'source'),
84
+ ...OWNER_FIELDS,
74
85
  ],
75
86
  },
76
87
  {
@@ -87,11 +98,22 @@ export function registerIntrospectionRoutes(
87
98
  },
88
99
  {
89
100
  kind: 'bindings',
90
- collect: (value) => ({ rows: value?.bindings?.() ?? [] }),
101
+ collect: async (value) => ({ rows: await value?.bindings?.() ?? [] }),
91
102
  fields: [
92
103
  (a) => stringProp(a, 'name'),
93
104
  (a) => stringProp(a, 'provider'),
94
105
  (a) => stringProp(a, 'model'),
106
+ (a) => stringProp(a, 'displayName'),
107
+ ],
108
+ },
109
+ {
110
+ // #1250: the Agent workbench's recent conversations come from the turn trace archive, newest first.
111
+ kind: 'recent-turns',
112
+ collect: async (value) => ({ rows: await value?.recentTurns?.() ?? [] }),
113
+ fields: [
114
+ (t) => stringProp(t, 'sessionDisplayName'),
115
+ (t) => stringProp(t, 'sessionKey'),
116
+ (t) => stringProp(t, 'status'),
95
117
  ],
96
118
  },
97
119
  {
@@ -101,6 +123,7 @@ export function registerIntrospectionRoutes(
101
123
  (t) => stringProp(t, 'name'),
102
124
  (t) => stringProp(t, 'source'),
103
125
  (t) => stringProp(t, 'description'),
126
+ ...OWNER_FIELDS,
104
127
  ],
105
128
  },
106
129
  {
@@ -114,6 +137,7 @@ export function registerIntrospectionRoutes(
114
137
  (section) => stringProp(section, 'layer'),
115
138
  (section) => stringProp(section, 'retention'),
116
139
  (section) => stringProp(section, 'source'),
140
+ ...OWNER_FIELDS,
117
141
  ],
118
142
  },
119
143
  {
@@ -136,7 +160,7 @@ export function registerIntrospectionRoutes(
136
160
  const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
137
161
  try {
138
162
  const introspection = lease?.value.introspection;
139
- const { rows, note } = collect(introspection);
163
+ const { rows, note } = await collect(introspection);
140
164
  const query = url.searchParams;
141
165
  const filter = query.get('filter')?.trim() || undefined;
142
166
  const filtered = filterByFields([...rows], filter, fields);