@zhin.js/host-http 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/access-control.d.ts +31 -3
- package/lib/access-control.js +96 -10
- package/lib/console-rest-pages-helpers.js +24 -2
- package/lib/console-rest-pages.d.ts +4 -1
- package/lib/console-rest-pages.js +49 -23
- package/lib/console-rpc-extended/contracts.d.ts +22 -0
- package/lib/console-rpc-extended/dispatcher.js +1 -1
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.js +78 -12
- package/lib/console-rpc-handlers.d.ts +9 -1
- package/lib/console-rpc-handlers.js +97 -10
- package/lib/console-rpc-types.d.ts +2 -0
- package/lib/console-rpc-types.js +43 -0
- package/lib/console-rpc.d.ts +29 -1
- package/lib/console-rpc.js +5 -4
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +5 -3
- package/lib/index.js +3 -1
- package/lib/project-files.d.ts +7 -0
- package/lib/project-files.js +88 -11
- package/lib/unknown-rpc.d.ts +12 -0
- package/lib/unknown-rpc.js +33 -0
- package/package.json +3 -3
- package/src/access-control.ts +132 -11
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +18 -0
- package/src/console-rpc-extended/dispatcher.ts +1 -1
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +1 -0
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +99 -9
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +31 -4
- package/src/http-host.ts +6 -2
- package/src/index.ts +10 -0
- package/src/project-files.ts +95 -11
- package/src/unknown-rpc.ts +44 -0
package/lib/project-files.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
113
|
-
|
|
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
|
|
121
|
-
if (!
|
|
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
|
|
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.
|
|
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.
|
|
32
|
+
"@zhin.js/plugin-runtime": "1.3.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^26.1.2",
|
package/src/access-control.ts
CHANGED
|
@@ -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<{
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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({
|
|
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<{
|
|
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({
|
|
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) =>
|
|
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);
|