@zhin.js/host-http 1.1.9 → 1.1.11
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/console-rest-pages.d.ts +1 -0
- package/lib/console-rest-pages.js +33 -3
- package/lib/console-rpc.d.ts +8 -1
- package/lib/console-rpc.js +46 -0
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/token-registry.d.ts +28 -1
- package/lib/token-registry.js +49 -5
- package/package.json +3 -3
- package/src/console-rest-pages.ts +34 -4
- package/src/console-rpc.ts +51 -0
- package/src/index.ts +3 -0
- package/src/token-registry.ts +72 -7
|
@@ -66,6 +66,7 @@ export interface ConsoleAgentSessionTree {
|
|
|
66
66
|
resolveActiveSessionId(sessionKey: string): Promise<string | null>;
|
|
67
67
|
getActiveLeafMessageId(sessionId: string): Promise<number | null>;
|
|
68
68
|
listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
|
|
69
|
+
readBranchMessage?(sessionId: string, messageId: number): Promise<string | null>;
|
|
69
70
|
switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
|
|
70
71
|
jumpToBranchIndex(sessionId: string, index: number): Promise<{
|
|
71
72
|
ok: boolean;
|
|
@@ -676,13 +676,22 @@ function registerAgentSessionRoutes(route, base, ctx) {
|
|
|
676
676
|
error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
|
|
677
677
|
});
|
|
678
678
|
};
|
|
679
|
-
// HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|leaf`。
|
|
679
|
+
// HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
|
|
680
680
|
route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
|
|
681
|
-
const
|
|
681
|
+
const messageAction = parseSessionAction(url.pathname, prefix, 'message');
|
|
682
|
+
if (messageAction)
|
|
683
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
684
|
+
const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
|
|
682
685
|
if (!parsed) {
|
|
683
686
|
writeJson(response, 404, { success: false, error: 'Not found' });
|
|
684
687
|
return;
|
|
685
688
|
}
|
|
689
|
+
const messageId = Number(url.searchParams.get('messageId'));
|
|
690
|
+
const expectedSessionId = url.searchParams.get('sessionId');
|
|
691
|
+
if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
|
|
692
|
+
writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
686
695
|
const lease = acquireSessionTree();
|
|
687
696
|
if (!lease) {
|
|
688
697
|
unavailable(response);
|
|
@@ -690,7 +699,28 @@ function registerAgentSessionRoutes(route, base, ctx) {
|
|
|
690
699
|
}
|
|
691
700
|
const runtime = lease.tree;
|
|
692
701
|
try {
|
|
702
|
+
if (messageAction && !runtime.readBranchMessage) {
|
|
703
|
+
writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
693
706
|
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
707
|
+
if (messageAction) {
|
|
708
|
+
if (!sessionId) {
|
|
709
|
+
writeJson(response, 404, { success: false, error: '未找到活跃会话' });
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (sessionId !== expectedSessionId) {
|
|
713
|
+
writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const text = await runtime.readBranchMessage(sessionId, messageId);
|
|
717
|
+
if (text === null) {
|
|
718
|
+
writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
writeJson(response, 200, { success: true, data: { messageId, text } });
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
694
724
|
if (!sessionId) {
|
|
695
725
|
if (!isRuntimeImSessionKey(parsed.sessionKey)) {
|
|
696
726
|
writeJson(response, 404, {
|
|
@@ -738,7 +768,7 @@ function registerAgentSessionRoutes(route, base, ctx) {
|
|
|
738
768
|
finally {
|
|
739
769
|
lease.release();
|
|
740
770
|
}
|
|
741
|
-
}, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
|
|
771
|
+
}, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
|
|
742
772
|
route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
|
|
743
773
|
const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
|
|
744
774
|
if (!parsed) {
|
package/lib/console-rpc.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AuthScope } from './token-registry.js';
|
|
2
2
|
import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
|
|
3
|
-
import { type ConsoleConfigSource, type ConsoleConfigExplanation, type ConsoleEndpointSummary } from '@zhin.js/console-protocol';
|
|
3
|
+
import { type ConsoleConfigPatchRequest, type ConsoleConfigPatchResult, type ConsoleConfigSource, type ConsoleConfigExplanation, type ConsoleEndpointSummary } from '@zhin.js/console-protocol';
|
|
4
4
|
import { type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
|
|
5
5
|
/**
|
|
6
6
|
* Console RPC 请求结构:未锚定 endpoint 的会话地址(结构对齐
|
|
@@ -96,7 +96,9 @@ export interface PluginVerificationResult {
|
|
|
96
96
|
readonly status: 'passed' | 'failed' | 'not-configured';
|
|
97
97
|
readonly scope: 'local' | 'platform' | null;
|
|
98
98
|
readonly summary: string;
|
|
99
|
+
readonly summaryI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['summaryI18n'];
|
|
99
100
|
readonly evidence: readonly string[];
|
|
101
|
+
readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
|
|
100
102
|
}
|
|
101
103
|
export interface PluginManagementPort {
|
|
102
104
|
explainConfiguration?(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
|
|
@@ -161,6 +163,11 @@ export type RuntimeConsoleRpcContext = {
|
|
|
161
163
|
setConfigKey?(pluginName: string, data: unknown): Promise<{
|
|
162
164
|
restartRequired: boolean;
|
|
163
165
|
}>;
|
|
166
|
+
/**
|
|
167
|
+
* Full-scope, path-allowlisted and revision-checked write (`config:patch`).
|
|
168
|
+
* Scope and schema checks belong to the implementation; scope violations throw.
|
|
169
|
+
*/
|
|
170
|
+
patchConfig?(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
|
|
164
171
|
/** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
|
|
165
172
|
setPluginEnabled?(instanceKey: string, enabled: boolean): Promise<Readonly<{
|
|
166
173
|
disabled: readonly string[];
|
package/lib/console-rpc.js
CHANGED
|
@@ -212,6 +212,52 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
212
212
|
}
|
|
213
213
|
return payloads;
|
|
214
214
|
}
|
|
215
|
+
case CONFIG_RPC.PATCH: {
|
|
216
|
+
if (ctx.authScope !== 'full') {
|
|
217
|
+
emit({ requestId, error: 'Full scope required' });
|
|
218
|
+
return payloads;
|
|
219
|
+
}
|
|
220
|
+
const { instanceKey, expectedRevision, patches } = message;
|
|
221
|
+
if (instanceKey !== undefined && (typeof instanceKey !== 'string' || !instanceKey || instanceKey.length > 128)) {
|
|
222
|
+
emit({ requestId, error: 'instanceKey 必须是插件实例名,或省略以使用 Host 白名单' });
|
|
223
|
+
return payloads;
|
|
224
|
+
}
|
|
225
|
+
if (typeof expectedRevision !== 'string' || !/^[a-f0-9]{64}$/u.test(expectedRevision)) {
|
|
226
|
+
emit({ requestId, error: 'expectedRevision is required' });
|
|
227
|
+
return payloads;
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(patches)) {
|
|
230
|
+
emit({ requestId, error: 'patches 必须是数组' });
|
|
231
|
+
return payloads;
|
|
232
|
+
}
|
|
233
|
+
if (!ctx.patchConfig) {
|
|
234
|
+
emit({ requestId, error: 'Config patch is not configured' });
|
|
235
|
+
return payloads;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const result = await ctx.patchConfig({
|
|
239
|
+
...(instanceKey === undefined ? {} : { instanceKey }),
|
|
240
|
+
expectedRevision,
|
|
241
|
+
patches,
|
|
242
|
+
});
|
|
243
|
+
if (result.status === 'saved') {
|
|
244
|
+
ctx.publishEvent?.('config:updated', {
|
|
245
|
+
pluginName: instanceKey ?? null,
|
|
246
|
+
keys: [...new Set(result.paths.map(path => path[0]))],
|
|
247
|
+
paths: result.paths,
|
|
248
|
+
revision: result.revision,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
emit({ requestId, data: result });
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
emit({
|
|
255
|
+
requestId,
|
|
256
|
+
error: `Failed to patch config: ${error instanceof Error ? error.message : String(error)}`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return payloads;
|
|
260
|
+
}
|
|
215
261
|
case CONFIG_RPC.SET: {
|
|
216
262
|
try {
|
|
217
263
|
const pluginName = message.pluginName;
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type ProcessHttpHost, type HttpHostAddress, type HttpHostOptions, type HttpHostTlsOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, type WsRouteOptions, } from './http-host.js';
|
|
2
2
|
export { createHttpHostGroup, listHttpHostAddresses, } from './listener-group.js';
|
|
3
|
-
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthenticatedTokenPrincipal, type AuthScope, type DynamicTokenConfig, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
|
|
3
|
+
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, publicConsoleAccess, unrestrictedConsoleAccess, type AuthenticatedTokenPrincipal, type ConsoleAccess, type AuthScope, type DynamicTokenConfig, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
|
|
4
4
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
5
5
|
export { createConsoleEventHub, consoleEventHubToken, type ConsoleEventHub, } from './console-events.js';
|
|
6
6
|
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
|
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
|
|
2
2
|
export { createHttpHostGroup, listHttpHostAddresses, } from './listener-group.js';
|
|
3
|
-
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
|
|
3
|
+
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, publicConsoleAccess, unrestrictedConsoleAccess, } from './token-registry.js';
|
|
4
4
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
5
5
|
export { createConsoleEventHub, consoleEventHubToken, } from './console-events.js';
|
|
6
6
|
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, } from './openapi.js';
|
package/lib/token-registry.d.ts
CHANGED
|
@@ -2,10 +2,32 @@
|
|
|
2
2
|
export type AuthScope = 'full' | 'demo';
|
|
3
3
|
export type ScopedTokenConfig = {
|
|
4
4
|
readonly token: string;
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `full` is the operator. `user` is an alias of `demo`: an ordinary Console user who sees and
|
|
7
|
+
* calls only what its `permissions` / `roles` allow. Stored and reported as `demo`.
|
|
8
|
+
*/
|
|
9
|
+
readonly scope: AuthScope | 'user';
|
|
6
10
|
/** Root-configured subject binding; never accepted from an HTTP payload. */
|
|
7
11
|
readonly principalId?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Console permissions held by this token (matched against Page `requiredPermissions`).
|
|
14
|
+
* Ignored for `full` scope, which already holds every Console permission.
|
|
15
|
+
*/
|
|
16
|
+
readonly permissions?: readonly string[];
|
|
17
|
+
/** Console roles held by this token (matched against Page `requiredRoles`). */
|
|
18
|
+
readonly roles?: readonly string[];
|
|
8
19
|
};
|
|
20
|
+
/**
|
|
21
|
+
* What a Console caller may see. `unrestricted` is the operator (full scope, or a Host with no
|
|
22
|
+
* tokens configured); otherwise only the listed permissions and roles count.
|
|
23
|
+
*/
|
|
24
|
+
export interface ConsoleAccess {
|
|
25
|
+
readonly unrestricted: boolean;
|
|
26
|
+
readonly permissions: readonly string[];
|
|
27
|
+
readonly roles: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
export declare const publicConsoleAccess: ConsoleAccess;
|
|
30
|
+
export declare const unrestrictedConsoleAccess: ConsoleAccess;
|
|
9
31
|
export interface AuthenticatedTokenPrincipal {
|
|
10
32
|
readonly principalId: string;
|
|
11
33
|
readonly scope: AuthScope;
|
|
@@ -23,6 +45,11 @@ export declare class TokenRegistry {
|
|
|
23
45
|
constructor(config?: TokenRegistryConfig);
|
|
24
46
|
resolve(token: string): AuthScope | null;
|
|
25
47
|
resolvePrincipal(token: string): AuthenticatedTokenPrincipal | null;
|
|
48
|
+
/**
|
|
49
|
+
* Console access for a bearer token. With no tokens configured the Host is local and open,
|
|
50
|
+
* so every caller is the operator. An unknown or missing token gets public access only.
|
|
51
|
+
*/
|
|
52
|
+
resolveAccess(token: string): ConsoleAccess;
|
|
26
53
|
hasAnyToken(): boolean;
|
|
27
54
|
/** Registers a runtime-issued credential and returns an idempotent revoker. */
|
|
28
55
|
register(config: DynamicTokenConfig): () => void;
|
package/lib/token-registry.js
CHANGED
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
import { timingSafeEqualString } from './timing-safe-equal.js';
|
|
2
|
+
export const publicConsoleAccess = Object.freeze({
|
|
3
|
+
unrestricted: false,
|
|
4
|
+
permissions: Object.freeze([]),
|
|
5
|
+
roles: Object.freeze([]),
|
|
6
|
+
});
|
|
7
|
+
export const unrestrictedConsoleAccess = Object.freeze({
|
|
8
|
+
unrestricted: true,
|
|
9
|
+
permissions: Object.freeze([]),
|
|
10
|
+
roles: Object.freeze([]),
|
|
11
|
+
});
|
|
2
12
|
export class TokenRegistry {
|
|
3
13
|
#entries = new Map();
|
|
4
14
|
constructor(config = {}) {
|
|
5
15
|
if (config.primaryToken) {
|
|
6
|
-
this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', dynamic: false }));
|
|
16
|
+
this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', permissions: [], roles: [], dynamic: false }));
|
|
7
17
|
}
|
|
8
|
-
for (const { token, scope, principalId } of config.scopedTokens ?? []) {
|
|
18
|
+
for (const { token, scope, principalId, permissions, roles } of config.scopedTokens ?? []) {
|
|
9
19
|
if (!token)
|
|
10
20
|
continue;
|
|
11
21
|
if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
|
|
12
22
|
throw new Error('HTTP token principalId is invalid');
|
|
13
23
|
}
|
|
14
24
|
const candidate = Object.freeze({
|
|
15
|
-
scope,
|
|
25
|
+
scope: scope === 'full' ? 'full' : 'demo',
|
|
16
26
|
...(principalId === undefined ? {} : { principalId }),
|
|
27
|
+
permissions: accessList(permissions, 'permissions'),
|
|
28
|
+
roles: accessList(roles, 'roles'),
|
|
17
29
|
dynamic: false,
|
|
18
30
|
});
|
|
19
31
|
const existing = this.#entries.get(token);
|
|
20
|
-
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId
|
|
32
|
+
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId
|
|
33
|
+
|| existing.permissions.join('\n') !== candidate.permissions.join('\n')
|
|
34
|
+
|| existing.roles.join('\n') !== candidate.roles.join('\n'))) {
|
|
21
35
|
throw new Error('HTTP token has conflicting authority bindings');
|
|
22
36
|
}
|
|
23
37
|
this.#entries.set(token, candidate);
|
|
@@ -49,6 +63,26 @@ export class TokenRegistry {
|
|
|
49
63
|
}
|
|
50
64
|
return null;
|
|
51
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Console access for a bearer token. With no tokens configured the Host is local and open,
|
|
68
|
+
* so every caller is the operator. An unknown or missing token gets public access only.
|
|
69
|
+
*/
|
|
70
|
+
resolveAccess(token) {
|
|
71
|
+
if (!this.hasAnyToken())
|
|
72
|
+
return unrestrictedConsoleAccess;
|
|
73
|
+
if (!token)
|
|
74
|
+
return publicConsoleAccess;
|
|
75
|
+
for (const [known, binding] of this.#entries) {
|
|
76
|
+
if (!timingSafeEqualString(known, token))
|
|
77
|
+
continue;
|
|
78
|
+
if (this.#expired(known, binding))
|
|
79
|
+
return publicConsoleAccess;
|
|
80
|
+
if (binding.scope === 'full')
|
|
81
|
+
return unrestrictedConsoleAccess;
|
|
82
|
+
return Object.freeze({ unrestricted: false, permissions: binding.permissions, roles: binding.roles });
|
|
83
|
+
}
|
|
84
|
+
return publicConsoleAccess;
|
|
85
|
+
}
|
|
52
86
|
hasAnyToken() {
|
|
53
87
|
for (const [token, binding] of this.#entries) {
|
|
54
88
|
if (!this.#expired(token, binding))
|
|
@@ -69,8 +103,10 @@ export class TokenRegistry {
|
|
|
69
103
|
throw new Error('Dynamic HTTP token expiresAt must be a future epoch millisecond');
|
|
70
104
|
}
|
|
71
105
|
const candidate = Object.freeze({
|
|
72
|
-
scope: config.scope,
|
|
106
|
+
scope: config.scope === 'full' ? 'full' : 'demo',
|
|
73
107
|
...(config.principalId === undefined ? {} : { principalId: config.principalId }),
|
|
108
|
+
permissions: accessList(config.permissions, 'permissions'),
|
|
109
|
+
roles: accessList(config.roles, 'roles'),
|
|
74
110
|
...(config.expiresAt === undefined ? {} : { expiresAt: config.expiresAt }),
|
|
75
111
|
dynamic: true,
|
|
76
112
|
});
|
|
@@ -107,6 +143,14 @@ export class TokenRegistry {
|
|
|
107
143
|
return true;
|
|
108
144
|
}
|
|
109
145
|
}
|
|
146
|
+
function accessList(value, label) {
|
|
147
|
+
if (value === undefined)
|
|
148
|
+
return Object.freeze([]);
|
|
149
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0 && item.trim() === item)) {
|
|
150
|
+
throw new Error(`HTTP token ${label} must be non-empty trimmed strings`);
|
|
151
|
+
}
|
|
152
|
+
return Object.freeze([...new Set(value)].sort());
|
|
153
|
+
}
|
|
110
154
|
/** WebSocket upgrade paths allowed for demo scope. */
|
|
111
155
|
export function isDemoWebSocketPath(pathname) {
|
|
112
156
|
return pathname === '/sandbox';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.11",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
],
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"ws": "^8.21.1",
|
|
20
|
-
"@zhin.js/console-protocol": "1.1.
|
|
20
|
+
"@zhin.js/console-protocol": "1.1.12",
|
|
21
21
|
"@zhin.js/host-http-contract": "1.1.1",
|
|
22
22
|
"@zhin.js/logger": "1.1.2",
|
|
23
23
|
"@zhin.js/schedule": "1.1.3",
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.17"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|
|
@@ -71,6 +71,7 @@ export interface ConsoleAgentSessionTree {
|
|
|
71
71
|
resolveActiveSessionId(sessionKey: string): Promise<string | null>;
|
|
72
72
|
getActiveLeafMessageId(sessionId: string): Promise<number | null>;
|
|
73
73
|
listBranchPoints(sessionId: string): Promise<readonly unknown[]>;
|
|
74
|
+
readBranchMessage?(sessionId: string, messageId: number): Promise<string | null>;
|
|
74
75
|
switchActiveLeaf(sessionId: string, messageId: number): Promise<boolean>;
|
|
75
76
|
jumpToBranchIndex(
|
|
76
77
|
sessionId: string,
|
|
@@ -872,13 +873,21 @@ function registerAgentSessionRoutes(
|
|
|
872
873
|
});
|
|
873
874
|
};
|
|
874
875
|
|
|
875
|
-
// HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|leaf`。
|
|
876
|
+
// HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
|
|
876
877
|
route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
|
|
877
|
-
const
|
|
878
|
+
const messageAction = parseSessionAction(url.pathname, prefix, 'message');
|
|
879
|
+
if (messageAction) response.setHeader('Cache-Control', 'no-store');
|
|
880
|
+
const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
|
|
878
881
|
if (!parsed) {
|
|
879
882
|
writeJson(response, 404, { success: false, error: 'Not found' });
|
|
880
883
|
return;
|
|
881
884
|
}
|
|
885
|
+
const messageId = Number(url.searchParams.get('messageId'));
|
|
886
|
+
const expectedSessionId = url.searchParams.get('sessionId');
|
|
887
|
+
if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
|
|
888
|
+
writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
882
891
|
const lease = acquireSessionTree();
|
|
883
892
|
if (!lease) {
|
|
884
893
|
unavailable(response);
|
|
@@ -886,7 +895,28 @@ function registerAgentSessionRoutes(
|
|
|
886
895
|
}
|
|
887
896
|
const runtime = lease.tree;
|
|
888
897
|
try {
|
|
898
|
+
if (messageAction && !runtime.readBranchMessage) {
|
|
899
|
+
writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
889
902
|
const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
|
|
903
|
+
if (messageAction) {
|
|
904
|
+
if (!sessionId) {
|
|
905
|
+
writeJson(response, 404, { success: false, error: '未找到活跃会话' });
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (sessionId !== expectedSessionId) {
|
|
909
|
+
writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const text = await runtime.readBranchMessage!(sessionId, messageId);
|
|
913
|
+
if (text === null) {
|
|
914
|
+
writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
writeJson(response, 200, { success: true, data: { messageId, text } });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
890
920
|
if (!sessionId) {
|
|
891
921
|
if (!isRuntimeImSessionKey(parsed.sessionKey)) {
|
|
892
922
|
writeJson(response, 404, {
|
|
@@ -934,7 +964,7 @@ function registerAgentSessionRoutes(
|
|
|
934
964
|
} finally {
|
|
935
965
|
lease.release();
|
|
936
966
|
}
|
|
937
|
-
}, { summary: 'Get agent session tree', tags: ['console', 'agent'] });
|
|
967
|
+
}, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
|
|
938
968
|
|
|
939
969
|
route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
|
|
940
970
|
const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
|
|
@@ -1038,7 +1068,7 @@ function isRuntimeImSessionKey(value: string): boolean {
|
|
|
1038
1068
|
function parseSessionAction(
|
|
1039
1069
|
pathname: string,
|
|
1040
1070
|
prefix: string,
|
|
1041
|
-
action: 'tree' | 'leaf',
|
|
1071
|
+
action: 'tree' | 'message' | 'leaf',
|
|
1042
1072
|
): { sessionKey: string } | null {
|
|
1043
1073
|
if (!pathname.startsWith(prefix)) return null;
|
|
1044
1074
|
const rest = pathname.slice(prefix.length).replace(/\/+$/u, '');
|
package/src/console-rpc.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
|
|
|
3
3
|
import {
|
|
4
4
|
CONFIG_RPC,
|
|
5
5
|
ENDPOINT_RPC,
|
|
6
|
+
type ConsoleConfigPatchRequest,
|
|
7
|
+
type ConsoleConfigPatchResult,
|
|
6
8
|
type ConsoleConfigSource,
|
|
7
9
|
type ConsoleConfigExplanation,
|
|
8
10
|
type ConsoleEndpointSummary,
|
|
@@ -133,7 +135,9 @@ export interface PluginVerificationResult {
|
|
|
133
135
|
readonly status: 'passed' | 'failed' | 'not-configured';
|
|
134
136
|
readonly scope: 'local' | 'platform' | null;
|
|
135
137
|
readonly summary: string;
|
|
138
|
+
readonly summaryI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['summaryI18n'];
|
|
136
139
|
readonly evidence: readonly string[];
|
|
140
|
+
readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
export interface PluginManagementPort {
|
|
@@ -194,6 +198,11 @@ export type RuntimeConsoleRpcContext = {
|
|
|
194
198
|
* Returns whether a process restart is required.
|
|
195
199
|
*/
|
|
196
200
|
setConfigKey?(pluginName: string, data: unknown): Promise<{ restartRequired: boolean }>;
|
|
201
|
+
/**
|
|
202
|
+
* Full-scope, path-allowlisted and revision-checked write (`config:patch`).
|
|
203
|
+
* Scope and schema checks belong to the implementation; scope violations throw.
|
|
204
|
+
*/
|
|
205
|
+
patchConfig?(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
|
|
197
206
|
/** Persist a declared child Plugin lifecycle state. Takes effect after restart. */
|
|
198
207
|
setPluginEnabled?(
|
|
199
208
|
instanceKey: string,
|
|
@@ -454,6 +463,48 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
454
463
|
}
|
|
455
464
|
return payloads;
|
|
456
465
|
}
|
|
466
|
+
case CONFIG_RPC.PATCH: {
|
|
467
|
+
if (ctx.authScope !== 'full') { emit({ requestId, error: 'Full scope required' }); return payloads; }
|
|
468
|
+
const { instanceKey, expectedRevision, patches } = message;
|
|
469
|
+
if (instanceKey !== undefined && (typeof instanceKey !== 'string' || !instanceKey || instanceKey.length > 128)) {
|
|
470
|
+
emit({ requestId, error: 'instanceKey 必须是插件实例名,或省略以使用 Host 白名单' });
|
|
471
|
+
return payloads;
|
|
472
|
+
}
|
|
473
|
+
if (typeof expectedRevision !== 'string' || !/^[a-f0-9]{64}$/u.test(expectedRevision)) {
|
|
474
|
+
emit({ requestId, error: 'expectedRevision is required' });
|
|
475
|
+
return payloads;
|
|
476
|
+
}
|
|
477
|
+
if (!Array.isArray(patches)) {
|
|
478
|
+
emit({ requestId, error: 'patches 必须是数组' });
|
|
479
|
+
return payloads;
|
|
480
|
+
}
|
|
481
|
+
if (!ctx.patchConfig) {
|
|
482
|
+
emit({ requestId, error: 'Config patch is not configured' });
|
|
483
|
+
return payloads;
|
|
484
|
+
}
|
|
485
|
+
try {
|
|
486
|
+
const result = await ctx.patchConfig({
|
|
487
|
+
...(instanceKey === undefined ? {} : { instanceKey }),
|
|
488
|
+
expectedRevision,
|
|
489
|
+
patches,
|
|
490
|
+
});
|
|
491
|
+
if (result.status === 'saved') {
|
|
492
|
+
ctx.publishEvent?.('config:updated', {
|
|
493
|
+
pluginName: instanceKey ?? null,
|
|
494
|
+
keys: [...new Set(result.paths.map(path => path[0]))],
|
|
495
|
+
paths: result.paths,
|
|
496
|
+
revision: result.revision,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
emit({ requestId, data: result });
|
|
500
|
+
} catch (error) {
|
|
501
|
+
emit({
|
|
502
|
+
requestId,
|
|
503
|
+
error: `Failed to patch config: ${error instanceof Error ? error.message : String(error)}`,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return payloads;
|
|
507
|
+
}
|
|
457
508
|
case CONFIG_RPC.SET: {
|
|
458
509
|
try {
|
|
459
510
|
const pluginName = message.pluginName;
|
package/src/index.ts
CHANGED
|
@@ -21,7 +21,10 @@ export {
|
|
|
21
21
|
TokenRegistry,
|
|
22
22
|
extractBearerToken,
|
|
23
23
|
isDemoWebSocketPath,
|
|
24
|
+
publicConsoleAccess,
|
|
25
|
+
unrestrictedConsoleAccess,
|
|
24
26
|
type AuthenticatedTokenPrincipal,
|
|
27
|
+
type ConsoleAccess,
|
|
25
28
|
type AuthScope,
|
|
26
29
|
type DynamicTokenConfig,
|
|
27
30
|
type ScopedTokenConfig,
|
package/src/token-registry.ts
CHANGED
|
@@ -5,11 +5,44 @@ export type AuthScope = 'full' | 'demo';
|
|
|
5
5
|
|
|
6
6
|
export type ScopedTokenConfig = {
|
|
7
7
|
readonly token: string;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* `full` is the operator. `user` is an alias of `demo`: an ordinary Console user who sees and
|
|
10
|
+
* calls only what its `permissions` / `roles` allow. Stored and reported as `demo`.
|
|
11
|
+
*/
|
|
12
|
+
readonly scope: AuthScope | 'user';
|
|
9
13
|
/** Root-configured subject binding; never accepted from an HTTP payload. */
|
|
10
14
|
readonly principalId?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Console permissions held by this token (matched against Page `requiredPermissions`).
|
|
17
|
+
* Ignored for `full` scope, which already holds every Console permission.
|
|
18
|
+
*/
|
|
19
|
+
readonly permissions?: readonly string[];
|
|
20
|
+
/** Console roles held by this token (matched against Page `requiredRoles`). */
|
|
21
|
+
readonly roles?: readonly string[];
|
|
11
22
|
};
|
|
12
23
|
|
|
24
|
+
/**
|
|
25
|
+
* What a Console caller may see. `unrestricted` is the operator (full scope, or a Host with no
|
|
26
|
+
* tokens configured); otherwise only the listed permissions and roles count.
|
|
27
|
+
*/
|
|
28
|
+
export interface ConsoleAccess {
|
|
29
|
+
readonly unrestricted: boolean;
|
|
30
|
+
readonly permissions: readonly string[];
|
|
31
|
+
readonly roles: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const publicConsoleAccess: ConsoleAccess = Object.freeze({
|
|
35
|
+
unrestricted: false,
|
|
36
|
+
permissions: Object.freeze([]) as readonly string[],
|
|
37
|
+
roles: Object.freeze([]) as readonly string[],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const unrestrictedConsoleAccess: ConsoleAccess = Object.freeze({
|
|
41
|
+
unrestricted: true,
|
|
42
|
+
permissions: Object.freeze([]) as readonly string[],
|
|
43
|
+
roles: Object.freeze([]) as readonly string[],
|
|
44
|
+
});
|
|
45
|
+
|
|
13
46
|
export interface AuthenticatedTokenPrincipal {
|
|
14
47
|
readonly principalId: string;
|
|
15
48
|
readonly scope: AuthScope;
|
|
@@ -28,6 +61,8 @@ export type DynamicTokenConfig = ScopedTokenConfig & {
|
|
|
28
61
|
type TokenBinding = Readonly<{
|
|
29
62
|
scope: AuthScope;
|
|
30
63
|
principalId?: string;
|
|
64
|
+
permissions: readonly string[];
|
|
65
|
+
roles: readonly string[];
|
|
31
66
|
expiresAt?: number;
|
|
32
67
|
dynamic: boolean;
|
|
33
68
|
}>;
|
|
@@ -37,20 +72,24 @@ export class TokenRegistry {
|
|
|
37
72
|
|
|
38
73
|
constructor(config: TokenRegistryConfig = {}) {
|
|
39
74
|
if (config.primaryToken) {
|
|
40
|
-
this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', dynamic: false }));
|
|
75
|
+
this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full', permissions: [], roles: [], dynamic: false }));
|
|
41
76
|
}
|
|
42
|
-
for (const { token, scope, principalId } of config.scopedTokens ?? []) {
|
|
77
|
+
for (const { token, scope, principalId, permissions, roles } of config.scopedTokens ?? []) {
|
|
43
78
|
if (!token) continue;
|
|
44
79
|
if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
|
|
45
80
|
throw new Error('HTTP token principalId is invalid');
|
|
46
81
|
}
|
|
47
|
-
const candidate = Object.freeze({
|
|
48
|
-
scope,
|
|
82
|
+
const candidate: TokenBinding = Object.freeze({
|
|
83
|
+
scope: scope === 'full' ? 'full' : 'demo',
|
|
49
84
|
...(principalId === undefined ? {} : { principalId }),
|
|
85
|
+
permissions: accessList(permissions, 'permissions'),
|
|
86
|
+
roles: accessList(roles, 'roles'),
|
|
50
87
|
dynamic: false,
|
|
51
88
|
});
|
|
52
89
|
const existing = this.#entries.get(token);
|
|
53
|
-
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId
|
|
90
|
+
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId
|
|
91
|
+
|| existing.permissions.join('\n') !== candidate.permissions.join('\n')
|
|
92
|
+
|| existing.roles.join('\n') !== candidate.roles.join('\n'))) {
|
|
54
93
|
throw new Error('HTTP token has conflicting authority bindings');
|
|
55
94
|
}
|
|
56
95
|
this.#entries.set(token, candidate);
|
|
@@ -80,6 +119,22 @@ export class TokenRegistry {
|
|
|
80
119
|
return null;
|
|
81
120
|
}
|
|
82
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Console access for a bearer token. With no tokens configured the Host is local and open,
|
|
124
|
+
* so every caller is the operator. An unknown or missing token gets public access only.
|
|
125
|
+
*/
|
|
126
|
+
resolveAccess(token: string): ConsoleAccess {
|
|
127
|
+
if (!this.hasAnyToken()) return unrestrictedConsoleAccess;
|
|
128
|
+
if (!token) return publicConsoleAccess;
|
|
129
|
+
for (const [known, binding] of this.#entries) {
|
|
130
|
+
if (!timingSafeEqualString(known, token)) continue;
|
|
131
|
+
if (this.#expired(known, binding)) return publicConsoleAccess;
|
|
132
|
+
if (binding.scope === 'full') return unrestrictedConsoleAccess;
|
|
133
|
+
return Object.freeze({ unrestricted: false, permissions: binding.permissions, roles: binding.roles });
|
|
134
|
+
}
|
|
135
|
+
return publicConsoleAccess;
|
|
136
|
+
}
|
|
137
|
+
|
|
83
138
|
hasAnyToken(): boolean {
|
|
84
139
|
for (const [token, binding] of this.#entries) {
|
|
85
140
|
if (!this.#expired(token, binding)) return true;
|
|
@@ -99,8 +154,10 @@ export class TokenRegistry {
|
|
|
99
154
|
throw new Error('Dynamic HTTP token expiresAt must be a future epoch millisecond');
|
|
100
155
|
}
|
|
101
156
|
const candidate: TokenBinding = Object.freeze({
|
|
102
|
-
scope: config.scope,
|
|
157
|
+
scope: config.scope === 'full' ? 'full' : 'demo',
|
|
103
158
|
...(config.principalId === undefined ? {} : { principalId: config.principalId }),
|
|
159
|
+
permissions: accessList(config.permissions, 'permissions'),
|
|
160
|
+
roles: accessList(config.roles, 'roles'),
|
|
104
161
|
...(config.expiresAt === undefined ? {} : { expiresAt: config.expiresAt }),
|
|
105
162
|
dynamic: true,
|
|
106
163
|
});
|
|
@@ -135,6 +192,14 @@ export class TokenRegistry {
|
|
|
135
192
|
}
|
|
136
193
|
}
|
|
137
194
|
|
|
195
|
+
function accessList(value: readonly string[] | undefined, label: string): readonly string[] {
|
|
196
|
+
if (value === undefined) return Object.freeze([]);
|
|
197
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0 && item.trim() === item)) {
|
|
198
|
+
throw new Error(`HTTP token ${label} must be non-empty trimmed strings`);
|
|
199
|
+
}
|
|
200
|
+
return Object.freeze([...new Set(value)].sort());
|
|
201
|
+
}
|
|
202
|
+
|
|
138
203
|
/** WebSocket upgrade paths allowed for demo scope. */
|
|
139
204
|
export function isDemoWebSocketPath(pathname: string): boolean {
|
|
140
205
|
return pathname === '/sandbox';
|