@zhin.js/host-http 1.2.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/access-control.d.ts +41 -3
- package/lib/access-control.js +112 -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 +108 -12
- 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 +13 -5
- package/lib/host-installer.js +10 -5
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +6 -3
- package/lib/index.js +4 -1
- package/lib/node/listener.js +2 -1
- package/lib/port.d.ts +27 -0
- package/lib/port.js +68 -0
- 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 +4 -4
- package/src/access-control.ts +151 -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 +110 -11
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +39 -5
- package/src/host-installer.ts +10 -4
- package/src/http-host.ts +6 -2
- package/src/index.ts +21 -0
- package/src/node/listener.ts +2 -1
- package/src/port.ts +60 -0
- package/src/project-files.ts +95 -11
- package/src/unknown-rpc.ts +44 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC types the runtime Console dispatcher of this package answers (`dispatchRuntimeConsoleRpc`:
|
|
3
|
+
* shell, configuration, plugin management, project files / environment / database, endpoints,
|
|
4
|
+
* schedules, inbox, login assist, workrooms). Used to tell an RPC this Host has but forgot to map
|
|
5
|
+
* (audited `unmapped`) from one it does not have at all (`unknown_rpc`, zhinjs/zhin#1250), and to
|
|
6
|
+
* build the Host's capability list. `console-rpc-types.test.ts` keeps it in step with the
|
|
7
|
+
* dispatchers' `case` labels.
|
|
8
|
+
*/
|
|
9
|
+
import { CONFIG_RPC, ENDPOINT_RPC, PLUGIN_RPC } from '@zhin.js/console-protocol';
|
|
10
|
+
|
|
11
|
+
export const RUNTIME_CONSOLE_RPC_TYPES: ReadonlySet<string> = new Set<string>([
|
|
12
|
+
// Shell
|
|
13
|
+
'ping', 'entries:get', 'pages:list',
|
|
14
|
+
// Configuration
|
|
15
|
+
...Object.values(CONFIG_RPC),
|
|
16
|
+
'workrooms:get', 'workrooms:set',
|
|
17
|
+
// Plugin management (the global switch is refused with a pointer to the gateway)
|
|
18
|
+
...Object.values(PLUGIN_RPC),
|
|
19
|
+
// Project files / environment / schemas / database / process
|
|
20
|
+
'files:tree', 'files:read', 'files:save',
|
|
21
|
+
'env:list', 'env:get', 'env:reveal', 'env:save',
|
|
22
|
+
'schema:get', 'schema:get-all',
|
|
23
|
+
'db:info', 'db:tables', 'db:select', 'db:query', 'db:execute',
|
|
24
|
+
'db:schema:describe', 'db:schema:preview', 'db:schema:apply',
|
|
25
|
+
'db:insert', 'db:update', 'db:delete', 'db:drop-table',
|
|
26
|
+
'db:kv:get', 'db:kv:set', 'db:kv:delete', 'db:kv:entries',
|
|
27
|
+
'system:restart',
|
|
28
|
+
// Endpoints
|
|
29
|
+
...Object.values(ENDPOINT_RPC),
|
|
30
|
+
// Schedules
|
|
31
|
+
'schedule:list', 'cron:list', 'cron:validate', 'cron:add', 'cron:remove', 'cron:pause', 'cron:resume',
|
|
32
|
+
// Requests / notices / inbox
|
|
33
|
+
'request.list', 'request.approve', 'request.reject', 'request.consumed', 'notice.consumed',
|
|
34
|
+
'inbox.requests', 'inbox.notices', 'inbox.messages', 'inbox.recent',
|
|
35
|
+
// Login assist
|
|
36
|
+
'login.list', 'login.submit', 'login.cancel', 'login.flow.list', 'login.flow.get', 'login.relogin', 'login.refresh',
|
|
37
|
+
// Workroom profiles / knowledge
|
|
38
|
+
'workroom.profile.status', 'workroom.profile.bootstrap', 'workroom.profile.pack.publish',
|
|
39
|
+
'workroom.profile.publish', 'workroom.profile.rollback', 'workroom.profile.policy.publish',
|
|
40
|
+
'workroom.knowledge.get', 'workroom.knowledge.publish', 'workroom.knowledge.rollback',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
export function isRuntimeConsoleRpcType(type: unknown): type is string {
|
|
44
|
+
return typeof type === 'string' && RUNTIME_CONSOLE_RPC_TYPES.has(type);
|
|
45
|
+
}
|
package/src/console-rpc.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { DatabaseHostConsole } from '@zhin.js/plugin-runtime';
|
|
|
3
3
|
import {
|
|
4
4
|
CONFIG_PATCH_LIMITS,
|
|
5
5
|
CONFIG_RPC,
|
|
6
|
+
CONSOLE_UNKNOWN_RPC_CODE,
|
|
6
7
|
grantsAllow,
|
|
7
8
|
type ConsoleConfigPatchRequest,
|
|
8
9
|
type ConsoleConfigPatchResult,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
type ConsoleEndpointSummary,
|
|
13
14
|
} from '@zhin.js/console-protocol';
|
|
14
15
|
import { authorizeConsoleRpc, principalAllows, type ConsolePrincipal } from './access-control.js';
|
|
16
|
+
import { isRuntimeConsoleRpcType } from './console-rpc-types.js';
|
|
15
17
|
import { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx } from './console-rpc-extended/index.js';
|
|
16
18
|
import { dispatchPluginConsoleRpc } from './console-plugin-rpc.js';
|
|
17
19
|
import {
|
|
@@ -128,9 +130,21 @@ export interface PluginVerificationResult {
|
|
|
128
130
|
readonly evidenceI18n?: import('@zhin.js/plugin-runtime').PluginVerificationOutcome['evidenceI18n'];
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
/** `inspect` options. `dependencies: 'skip'` leaves the project-wide graph check out (`dependencies: 'unchecked'`). */
|
|
134
|
+
export interface PluginInspectOptions {
|
|
135
|
+
readonly dependencies?: 'check' | 'skip';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Result of the project-wide dependency graph check (the same check `inspect` runs per plugin). */
|
|
139
|
+
export type PluginDependencyCheck =
|
|
140
|
+
| { readonly status: 'passed' }
|
|
141
|
+
| { readonly status: 'failed'; readonly error: string };
|
|
142
|
+
|
|
131
143
|
export interface PluginManagementPort {
|
|
132
144
|
explainConfiguration?(instanceKey: string, expectedRevision: string): Promise<ConsoleConfigExplanation>;
|
|
133
|
-
inspect?(packageName: string): Promise<PluginManagementState>;
|
|
145
|
+
inspect?(packageName: string, options?: PluginInspectOptions): Promise<PluginManagementState>;
|
|
146
|
+
/** Runs the project-wide dependency graph check once, for callers that inspect many plugins. */
|
|
147
|
+
checkDependencies?(): Promise<PluginDependencyCheck>;
|
|
134
148
|
apply?(packageName: string, expectedProjectRevision?: string): Promise<{ readonly requested: boolean; readonly state: PluginManagementState }>;
|
|
135
149
|
verificationPlan?(packageName: string): Promise<PluginVerificationPlan>;
|
|
136
150
|
verify?(packageName: string, checkId: string, expectedGeneration: number | null, expectedProjectRevision: string, expectedCheckRevision: string): Promise<PluginVerificationResult>;
|
|
@@ -209,8 +223,21 @@ export type RuntimeConsoleRpcContext = {
|
|
|
209
223
|
readProjectFile?(filePath: string): Promise<{ content: string; size: number }>;
|
|
210
224
|
saveProjectFile?(filePath: string, content: string): Promise<void>;
|
|
211
225
|
listEnvFiles?(): Promise<readonly { name: string; exists: boolean }[]>;
|
|
226
|
+
/**
|
|
227
|
+
* @deprecated Not used any more: `env:get` answers {@link readEnvFileView} only, so the file as
|
|
228
|
+
* written never reaches the Console (issue #1250).
|
|
229
|
+
*/
|
|
212
230
|
readEnvFile?(filename: string): Promise<string>;
|
|
231
|
+
/** @deprecated Not used any more: `env:save` writes through {@link updateEnvVariables} / {@link replaceEnvFile}. */
|
|
213
232
|
writeEnvFile?(filename: string, content: string): Promise<void>;
|
|
233
|
+
/** `env:get`: masked view (key names, masks, lengths; comments and blank lines in place). */
|
|
234
|
+
readEnvFileView?(filename: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvFileView>;
|
|
235
|
+
/** `env:reveal`: one variable as written. The route requires `ops.env.write` + `ops.env.read` and audits. */
|
|
236
|
+
revealEnvVariable?(filename: string, key: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvRevealResult>;
|
|
237
|
+
/** `env:save { changes }`: validates and writes only the named keys (mask placeholders refused). */
|
|
238
|
+
updateEnvVariables?(filename: string, changes: unknown): Promise<{ readonly changed: readonly string[] }>;
|
|
239
|
+
/** `env:save { content, mode: 'replace' }`: whole file; refused when it holds mask placeholders. */
|
|
240
|
+
replaceEnvFile?(filename: string, content: string): Promise<void>;
|
|
214
241
|
/** Optional schema accessors (plugin schema.json / Runtime schema registry). */
|
|
215
242
|
getSchema?(pluginName?: string): Promise<unknown>;
|
|
216
243
|
getAllSchemas?(): Promise<Record<string, unknown>>;
|
|
@@ -288,9 +315,9 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
288
315
|
let canOperateEndpoint: ConsoleRpcExtendedCtx['canOperateEndpoint'];
|
|
289
316
|
if (ctx.principal) {
|
|
290
317
|
const principal = ctx.principal;
|
|
291
|
-
const decision = authorizeConsoleRpc(principal, message as Record<string, unknown
|
|
318
|
+
const decision = authorizeConsoleRpc(principal, message as Record<string, unknown>, { registered: isRuntimeConsoleRpcType });
|
|
292
319
|
if (!decision.allowed) {
|
|
293
|
-
emit({ requestId, error: decision.error, code: decision.code });
|
|
320
|
+
emit({ requestId, error: decision.error, code: decision.code, ...(decision.reason ? { reason: decision.reason } : {}) });
|
|
294
321
|
return payloads;
|
|
295
322
|
}
|
|
296
323
|
authorizedEndpoint = scopedAuthorizedEndpoint(principal, decision);
|
|
@@ -507,7 +534,11 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
507
534
|
case CONFIG_RPC.PATCH: {
|
|
508
535
|
if (ctx.authScope !== 'full') { emit({ requestId, error: 'Full scope required' }); return payloads; }
|
|
509
536
|
const projectPatch = message.type === CONFIG_RPC.PATCH_PROJECT;
|
|
510
|
-
const { instanceKey, expectedRevision, patches } = message;
|
|
537
|
+
const { instanceKey, expectedRevision, patches, secretStorage } = message;
|
|
538
|
+
if (secretStorage !== undefined && secretStorage !== 'env' && secretStorage !== 'plain') {
|
|
539
|
+
emit({ requestId, error: 'secretStorage 只能是 env 或 plain' });
|
|
540
|
+
return payloads;
|
|
541
|
+
}
|
|
511
542
|
if (projectPatch && instanceKey !== undefined) {
|
|
512
543
|
emit({ requestId, error: 'Project patches must not specify instanceKey' });
|
|
513
544
|
return payloads;
|
|
@@ -539,6 +570,7 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
539
570
|
...(instanceKey === undefined ? {} : { instanceKey }),
|
|
540
571
|
expectedRevision,
|
|
541
572
|
patches,
|
|
573
|
+
...(secretStorage === undefined ? {} : { secretStorage }),
|
|
542
574
|
});
|
|
543
575
|
} catch (error) {
|
|
544
576
|
emit({
|
|
@@ -600,9 +632,11 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
600
632
|
},
|
|
601
633
|
});
|
|
602
634
|
} catch (error) {
|
|
635
|
+
const code = error instanceof Error && 'code' in error && error.code === 'env-write-required' ? { code: 'env-write-required' } : {};
|
|
603
636
|
emit({
|
|
604
637
|
requestId,
|
|
605
638
|
error: `Failed to set config: ${error instanceof Error ? error.message : String(error)}`,
|
|
639
|
+
...code,
|
|
606
640
|
});
|
|
607
641
|
}
|
|
608
642
|
return payloads;
|
|
@@ -618,7 +652,7 @@ export async function dispatchRuntimeConsoleRpc(
|
|
|
618
652
|
});
|
|
619
653
|
return payloads;
|
|
620
654
|
}
|
|
621
|
-
emit({ requestId, error: `Unknown RPC type: ${type || '<empty>'}
|
|
655
|
+
emit({ requestId, error: `Unknown RPC type: ${type.slice(0, 64) || '<empty>'}`, code: CONSOLE_UNKNOWN_RPC_CODE });
|
|
622
656
|
return payloads;
|
|
623
657
|
}
|
|
624
658
|
}
|
package/src/host-installer.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { isAbsolute, resolve } from 'node:path';
|
|
|
11
11
|
import { httpHostToken, type HttpHost } from './http-host.js';
|
|
12
12
|
import type { HttpHostOptions } from './host-options.js';
|
|
13
13
|
import { parseTrustProxy } from './trust-proxy.js';
|
|
14
|
+
import { parseHttpPort } from './port.js';
|
|
14
15
|
|
|
15
16
|
export interface ResolvedHttpConfig extends HttpHostOptions {
|
|
16
17
|
/** Additional listeners that mirror the primary Host route surface. */
|
|
@@ -30,17 +31,19 @@ export async function resolveHttpConfig(
|
|
|
30
31
|
http,
|
|
31
32
|
(key) => envOverlay?.[key] ?? process.env[key],
|
|
32
33
|
) as Record<string, unknown>;
|
|
34
|
+
const stored = http as Record<string, unknown>;
|
|
33
35
|
if (value.tokens !== undefined && value.tokens !== null) throw new Error(HTTP_TOKENS_REMOVED_MESSAGE);
|
|
34
36
|
const primary: HttpHostOptions = Object.freeze({
|
|
35
37
|
host: typeof value.host === 'string' ? value.host : undefined,
|
|
36
|
-
|
|
38
|
+
// An invalid port is an error, never a silent fallback to the default (#1250).
|
|
39
|
+
port: parseHttpPort(value.port, 'http.port', stored.port),
|
|
37
40
|
token: typeof value.token === 'string' ? value.token : undefined,
|
|
38
41
|
corsOrigins: injectDesktopWebviewOrigins(parseStringList(value.corsOrigins), envOverlay),
|
|
39
42
|
apiBase: typeof value.base === 'string' ? value.base : undefined,
|
|
40
43
|
tls: await parseTlsConfig(value.tls, projectRoot),
|
|
41
44
|
trustProxy: parseTrustProxy(value.trustProxy),
|
|
42
45
|
});
|
|
43
|
-
const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot);
|
|
46
|
+
const configuredListeners = await parseAdditionalListeners(value.listeners, primary, projectRoot, stored.listeners);
|
|
44
47
|
const listeners = await injectDesktopDeviceListener(
|
|
45
48
|
configuredListeners,
|
|
46
49
|
primary,
|
|
@@ -110,6 +113,7 @@ async function parseAdditionalListeners(
|
|
|
110
113
|
value: unknown,
|
|
111
114
|
primary: HttpHostOptions,
|
|
112
115
|
projectRoot: string,
|
|
116
|
+
stored?: unknown,
|
|
113
117
|
): Promise<readonly HttpHostOptions[]> {
|
|
114
118
|
if (value === undefined) return Object.freeze([]);
|
|
115
119
|
if (!Array.isArray(value)) throw new Error('http.listeners must be an array');
|
|
@@ -123,13 +127,15 @@ async function parseAdditionalListeners(
|
|
|
123
127
|
if (typeof listener.host !== 'string' || !listener.host) {
|
|
124
128
|
throw new Error(`http.listeners[${index}].host is required`);
|
|
125
129
|
}
|
|
126
|
-
|
|
130
|
+
const storedPort = Array.isArray(stored) ? (stored[index] as Record<string, unknown> | undefined)?.port : undefined;
|
|
131
|
+
const port = parseHttpPort(listener.port, `http.listeners[${index}].port`, storedPort);
|
|
132
|
+
if (port === undefined) {
|
|
127
133
|
throw new Error(`http.listeners[${index}].port is required`);
|
|
128
134
|
}
|
|
129
135
|
listeners.push(Object.freeze({
|
|
130
136
|
...primary,
|
|
131
137
|
host: listener.host,
|
|
132
|
-
port
|
|
138
|
+
port,
|
|
133
139
|
tls: await parseTlsConfig(listener.tls, projectRoot),
|
|
134
140
|
}));
|
|
135
141
|
}
|
package/src/http-host.ts
CHANGED
|
@@ -30,8 +30,10 @@ import {
|
|
|
30
30
|
authorizeConsoleHttp,
|
|
31
31
|
authorizeConsoleWebSocket,
|
|
32
32
|
requestMeta,
|
|
33
|
+
webSocketDenialReason,
|
|
33
34
|
type ConsolePrincipal,
|
|
34
35
|
} from './access-control.js';
|
|
36
|
+
import type { ConsoleDenialReason } from '@zhin.js/console-protocol';
|
|
35
37
|
import type { HttpHostAddress } from './host-options.js';
|
|
36
38
|
|
|
37
39
|
export type { HttpHostAddress } from './host-options.js';
|
|
@@ -301,7 +303,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
301
303
|
);
|
|
302
304
|
if (!principal) return undefined;
|
|
303
305
|
if (!authorizeConsoleWebSocket(principal, pathname)) {
|
|
304
|
-
audit(principal, remoteAddress, `ws:${pathname}`, 'denied');
|
|
306
|
+
audit(principal, remoteAddress, `ws:${pathname}`, 'denied', webSocketDenialReason(principal, pathname));
|
|
305
307
|
return undefined;
|
|
306
308
|
}
|
|
307
309
|
return principal;
|
|
@@ -312,6 +314,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
312
314
|
remoteAddress: string | undefined,
|
|
313
315
|
action: string,
|
|
314
316
|
outcome: 'ok' | 'denied' | 'error',
|
|
317
|
+
reason?: ConsoleDenialReason,
|
|
315
318
|
): void {
|
|
316
319
|
const sink = tokenRegistry.accessProvider?.audit;
|
|
317
320
|
if (!sink) return;
|
|
@@ -320,6 +323,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
320
323
|
actor: auditActor(principal),
|
|
321
324
|
action,
|
|
322
325
|
outcome,
|
|
326
|
+
...(reason ? { detail: { reason } } : {}),
|
|
323
327
|
...(remoteAddress ? { ip: remoteAddress } : {}),
|
|
324
328
|
});
|
|
325
329
|
} catch { /* audit never breaks a request */ }
|
|
@@ -508,7 +512,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
508
512
|
const relative = pathname === apiBase ? '/' : pathname.slice(apiBase.length);
|
|
509
513
|
const decision = authorizeConsoleHttp(principal, method, relative);
|
|
510
514
|
if (!decision.allowed) {
|
|
511
|
-
if (principal) audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied');
|
|
515
|
+
if (principal) audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied', decision.reason);
|
|
512
516
|
return { ok: false, status: decision.status, code: decision.code, error: decision.error };
|
|
513
517
|
}
|
|
514
518
|
return { ok: true, ...(principal ? { principal } : {}) };
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,13 @@ export {
|
|
|
20
20
|
} from './http-host.js';
|
|
21
21
|
export type { HttpHostAddress, HttpHostOptions, HttpHostTlsOptions } from './host-options.js';
|
|
22
22
|
export { listHttpHostAddresses } from './addresses.js';
|
|
23
|
+
export {
|
|
24
|
+
DEFAULT_HTTP_PORT,
|
|
25
|
+
ENVIRONMENT_REFERENCE_PATTERN,
|
|
26
|
+
InvalidHttpPortError,
|
|
27
|
+
describeInvalidPortValue,
|
|
28
|
+
parseHttpPort,
|
|
29
|
+
} from './port.js';
|
|
23
30
|
export {
|
|
24
31
|
TokenRegistry,
|
|
25
32
|
extractBearerToken,
|
|
@@ -36,8 +43,17 @@ export {
|
|
|
36
43
|
authorizeConsoleRpc,
|
|
37
44
|
authorizeConsoleWebSocket,
|
|
38
45
|
globalPermissions,
|
|
46
|
+
grantsDenialReason,
|
|
47
|
+
isAuditedRpcDenial,
|
|
48
|
+
unknownConsoleRpcError,
|
|
49
|
+
webSocketDenialReason,
|
|
50
|
+
type AuthorizeConsoleRpcOptions,
|
|
39
51
|
isUserPrincipal,
|
|
40
52
|
isExplicitConsolePrincipal,
|
|
53
|
+
isGlobalOwnerPrincipal,
|
|
54
|
+
isLocalSocketPrincipal,
|
|
55
|
+
LOCAL_SOCKET_CREDENTIAL_ID,
|
|
56
|
+
LOCAL_SOCKET_USER_ID,
|
|
41
57
|
pluginRpcWriteAllowed,
|
|
42
58
|
principalAllows,
|
|
43
59
|
requestMeta,
|
|
@@ -93,6 +109,8 @@ export {
|
|
|
93
109
|
type HttpResponseStream,
|
|
94
110
|
type HttpResponseStreamOptions,
|
|
95
111
|
} from '@zhin.js/host-http-contract';
|
|
112
|
+
export { isRuntimeConsoleRpcType, RUNTIME_CONSOLE_RPC_TYPES } from './console-rpc-types.js';
|
|
113
|
+
export { createUnknownRpcLog, unknownRpcLog, type UnknownRpcLog, type UnknownRpcLogEntry } from './unknown-rpc.js';
|
|
96
114
|
export {
|
|
97
115
|
dispatchRuntimeConsoleRpc,
|
|
98
116
|
pickRpcReply,
|
|
@@ -106,6 +124,8 @@ export {
|
|
|
106
124
|
type PluginUpdatePlan,
|
|
107
125
|
type PluginManagementPort,
|
|
108
126
|
type PluginManagementState,
|
|
127
|
+
type PluginInspectOptions,
|
|
128
|
+
type PluginDependencyCheck,
|
|
109
129
|
type PluginVerificationResult,
|
|
110
130
|
type PluginVerificationPlan,
|
|
111
131
|
type PluginConfigValidation,
|
|
@@ -133,6 +153,7 @@ export {
|
|
|
133
153
|
dispatchExtendedConsoleRpc,
|
|
134
154
|
type ConsoleRpcExtendedCtx,
|
|
135
155
|
type ConsoleScheduleEngine,
|
|
156
|
+
type ConsoleScheduleTarget,
|
|
136
157
|
type ConsoleWorkroomProfileControlPort,
|
|
137
158
|
type ConsoleWorkroomKnowledgeControlPort,
|
|
138
159
|
type ExtendedRpcResult,
|
package/src/node/listener.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { WebSocketServer } from 'ws';
|
|
|
21
21
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
22
22
|
import type { HttpRequestInfo, WsUpgradeDecision, WsUpgradeRequest } from '../http-host.js';
|
|
23
23
|
import type { HttpHostAddress, HttpHostTlsOptions } from '../host-options.js';
|
|
24
|
+
import { DEFAULT_HTTP_PORT } from '../port.js';
|
|
24
25
|
import { compileTrustProxy, resolveClientAddress, type TrustProxySetting } from '../trust-proxy.js';
|
|
25
26
|
import { createNodeRequest, nodeRequestHeaders, writeNodeResponse, wrapNodeWebSocket } from './node-http.js';
|
|
26
27
|
|
|
@@ -52,7 +53,7 @@ export interface NodeHttpListener {
|
|
|
52
53
|
/** A Node listener over {@link NodeHttpListenerOptions.handle} / `upgrade`. */
|
|
53
54
|
export function createNodeHttpListener(options: NodeHttpListenerOptions): NodeHttpListener {
|
|
54
55
|
const host = options.host ?? '127.0.0.1';
|
|
55
|
-
const port = options.port ??
|
|
56
|
+
const port = options.port ?? DEFAULT_HTTP_PORT;
|
|
56
57
|
const secure = options.tls !== undefined;
|
|
57
58
|
let address: HttpHostAddress | undefined;
|
|
58
59
|
let closed = false;
|
package/src/port.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `http.port` (and `http.listeners[].port`): an integer from 1 to 65535, written either as a number
|
|
3
|
+
* or as a `${VAR}` environment reference that expands to one (#1250); `0` keeps its listener meaning
|
|
4
|
+
* (the system picks a free port — tests and embedded use). Anything else is a configuration error:
|
|
5
|
+
* the Host never falls back to the default port for an invalid value.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Port the Host listens on when `http.port` is not configured (same as the scaffold writes). */
|
|
9
|
+
export const DEFAULT_HTTP_PORT = 8068;
|
|
10
|
+
|
|
11
|
+
/** A whole `${VAR}` / `${VAR:-default}` reference (the form the config schema accepts for a port). */
|
|
12
|
+
export const ENVIRONMENT_REFERENCE_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::[-=][^}]*)?\}$/u;
|
|
13
|
+
|
|
14
|
+
export class InvalidHttpPortError extends Error {
|
|
15
|
+
constructor(readonly field: string, readonly value: unknown, readonly reason: string) {
|
|
16
|
+
super(`${field} 无效:${reason}`);
|
|
17
|
+
this.name = 'InvalidHttpPortError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function show(value: unknown): string {
|
|
22
|
+
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Why a stored (unexpanded) port value is invalid, in Chinese; undefined when it is valid.
|
|
27
|
+
* `undefined` / `null` (not configured) are valid; `${VAR}` references are checked after expansion.
|
|
28
|
+
*/
|
|
29
|
+
export function describeInvalidPortValue(value: unknown): string | undefined {
|
|
30
|
+
if (value === undefined || value === null) return undefined;
|
|
31
|
+
if (typeof value === 'number') {
|
|
32
|
+
if (!Number.isInteger(value)) return `端口必须是整数,当前值 ${value}`;
|
|
33
|
+
if (value < 0 || value > 65_535) return `端口必须在 1–65535 之间,当前值 ${value}`;
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
if (typeof value === 'string' && ENVIRONMENT_REFERENCE_PATTERN.test(value.trim())) return undefined;
|
|
37
|
+
return `端口必须是 1–65535 的整数或 \${VAR} 形式的环境变量引用,当前值 ${show(value)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The listening port from an expanded config value (`${VAR}` already replaced: a digit string is
|
|
42
|
+
* accepted). Returns undefined when not configured; throws {@link InvalidHttpPortError} otherwise.
|
|
43
|
+
* `raw` is the stored value, named in the message when it was an environment reference.
|
|
44
|
+
*/
|
|
45
|
+
export function parseHttpPort(value: unknown, field: string, raw?: unknown): number | undefined {
|
|
46
|
+
if (value === undefined || value === null) return undefined;
|
|
47
|
+
const fromReference = typeof raw === 'string' && ENVIRONMENT_REFERENCE_PATTERN.test(raw.trim()) ? `(来自 ${raw.trim()})` : '';
|
|
48
|
+
let port: number;
|
|
49
|
+
if (typeof value === 'number') port = value;
|
|
50
|
+
else if (typeof value === 'string' && /^\d+$/u.test(value.trim())) port = Number(value.trim());
|
|
51
|
+
else if (typeof value === 'string' && value.trim() === '' && fromReference) {
|
|
52
|
+
throw new InvalidHttpPortError(field, value, `环境变量未设置或为空${fromReference};请在 .env 中设置为 1–65535 的整数`);
|
|
53
|
+
} else {
|
|
54
|
+
throw new InvalidHttpPortError(field, value, `端口必须是 1–65535 的整数或 \${VAR} 形式的环境变量引用,当前值 ${show(value)}${fromReference}`);
|
|
55
|
+
}
|
|
56
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
57
|
+
throw new InvalidHttpPortError(field, value, `端口必须在 1–65535 之间,当前值 ${show(value)}${fromReference}`);
|
|
58
|
+
}
|
|
59
|
+
return port;
|
|
60
|
+
}
|
package/src/project-files.ts
CHANGED
|
@@ -27,7 +27,13 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
|
|
|
27
27
|
'plugin.mjs',
|
|
28
28
|
'plugin.cjs',
|
|
29
29
|
'schema.json',
|
|
30
|
+
// Agent workspace bootstrap files (`@zhin.js/agent` bootstrap.ts, timer heartbeat) and the
|
|
31
|
+
// conventional knowledge base directory (`ai.knowledge.baseDir`).
|
|
30
32
|
'AGENTS.md',
|
|
33
|
+
'SOUL.md',
|
|
34
|
+
'TOOLS.md',
|
|
35
|
+
'HEARTBEAT.md',
|
|
36
|
+
'knowledge',
|
|
31
37
|
'tsconfig.json',
|
|
32
38
|
'zhin.config.yml',
|
|
33
39
|
'zhin.config.yaml',
|
|
@@ -35,16 +41,12 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
|
|
|
35
41
|
'config.yml',
|
|
36
42
|
'config.yaml',
|
|
37
43
|
'config.json',
|
|
38
|
-
'.env',
|
|
39
|
-
'.env.development',
|
|
40
|
-
'.env.production',
|
|
41
44
|
'README.md',
|
|
42
45
|
]);
|
|
43
46
|
|
|
44
47
|
export const FILE_MANAGER_BLOCKED = Object.freeze(new Set([
|
|
45
48
|
'node_modules',
|
|
46
49
|
'.git',
|
|
47
|
-
'.env.local',
|
|
48
50
|
'data',
|
|
49
51
|
'lib',
|
|
50
52
|
'dist',
|
|
@@ -58,10 +60,73 @@ export type FileTreeNode = {
|
|
|
58
60
|
readonly children?: readonly FileTreeNode[];
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
/** Dotenv files the environment page (`/ops/env`, `env:*` RPCs) manages. */
|
|
64
|
+
const ENV_FILE_NAMES = Object.freeze(['.env', '.env.development', '.env.production'] as const);
|
|
65
|
+
|
|
66
|
+
/** Error text for project-file reads and writes that name a dotenv file (issue #1250). */
|
|
67
|
+
export const DOTENV_ACCESS_DENIED_MESSAGE =
|
|
68
|
+
'Access denied: .env files are not available in the project file manager; '
|
|
69
|
+
+ 'manage environment variables on the Environment variables page (/ops/env)';
|
|
70
|
+
|
|
71
|
+
// Control characters (NUL included) never name a project file and can truncate a path in native code.
|
|
72
|
+
// eslint-disable-next-line no-control-regex
|
|
73
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Strips trailing dots and spaces (what Windows drops from a file name). A linear scan: the
|
|
77
|
+
* equivalent `/[. ]+$/` backtracks quadratically on long runs of spaces (CodeQL js/polynomial-redos).
|
|
78
|
+
*/
|
|
79
|
+
function trimTrailingDotsAndSpaces(value: string): string {
|
|
80
|
+
let end = value.length;
|
|
81
|
+
while (end > 0 && (value[end - 1] === '.' || value[end - 1] === ' ')) end -= 1;
|
|
82
|
+
return value.slice(0, end);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether one path segment names a dotenv file (`.env`, `.env.*`), in every spelling a
|
|
87
|
+
* filesystem may resolve to it: any letter case, trailing dots / spaces (stripped by Windows),
|
|
88
|
+
* an NTFS stream suffix (`.env::$DATA`) and percent-encoding.
|
|
89
|
+
*/
|
|
90
|
+
function isDotenvSegment(segment: string): boolean {
|
|
91
|
+
const variants = [segment];
|
|
92
|
+
try {
|
|
93
|
+
const decoded = decodeURIComponent(segment);
|
|
94
|
+
if (decoded !== segment) variants.push(decoded);
|
|
95
|
+
} catch { /* not percent-encoded */ }
|
|
96
|
+
return variants.some((variant) => {
|
|
97
|
+
const name = trimTrailingDotsAndSpaces(variant.split(':')[0]!).toLowerCase();
|
|
98
|
+
return name === '.env' || name.startsWith('.env.');
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a project-relative path names a dotenv file or goes through one. Dotenv files hold
|
|
104
|
+
* secrets: the project file manager never lists, reads or writes them (`/ops/env` does).
|
|
105
|
+
*/
|
|
106
|
+
export function isDotenvProjectPath(relativePath: string): boolean {
|
|
107
|
+
return relativePath.replace(/\\/gu, '/').split('/').some(isDotenvSegment);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether one path segment names a dot file or dot directory (`.envrc`, `.npmrc`, `.ssh`, …), also
|
|
112
|
+
* percent-encoded. Such files commonly hold credentials; no entry of {@link FILE_MANAGER_ALLOWED}
|
|
113
|
+
* is one, so the project file manager never reads or writes them (the tree already skips them).
|
|
114
|
+
*/
|
|
115
|
+
function isDotSegment(segment: string): boolean {
|
|
116
|
+
const variants = [segment];
|
|
117
|
+
try {
|
|
118
|
+
const decoded = decodeURIComponent(segment);
|
|
119
|
+
if (decoded !== segment) variants.push(decoded);
|
|
120
|
+
} catch { /* not percent-encoded */ }
|
|
121
|
+
return variants.some(variant => variant.startsWith('.') && variant !== '.');
|
|
122
|
+
}
|
|
123
|
+
|
|
61
124
|
export function isProjectPathAllowed(relativePath: string): boolean {
|
|
62
125
|
if (relativePath.includes('..') || path.isAbsolute(relativePath)) return false;
|
|
126
|
+
if (CONTROL_CHARACTERS.test(relativePath) || isDotenvProjectPath(relativePath)) return false;
|
|
63
127
|
const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
|
|
64
|
-
|
|
128
|
+
const segments = normalized.split('/');
|
|
129
|
+
if (segments.some((segment) => FILE_MANAGER_BLOCKED.has(segment) || isDotSegment(segment))) return false;
|
|
65
130
|
return FILE_MANAGER_ALLOWED.some(
|
|
66
131
|
(allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`),
|
|
67
132
|
);
|
|
@@ -94,6 +159,7 @@ export async function readProjectFile(
|
|
|
94
159
|
projectRoot: string,
|
|
95
160
|
relativePath: string,
|
|
96
161
|
): Promise<{ content: string; size: number }> {
|
|
162
|
+
assertNotDotenv(relativePath);
|
|
97
163
|
const absPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
98
164
|
if (!absPath) throw new Error(`Access denied: ${relativePath}`);
|
|
99
165
|
if (!(await pathExists(absPath))) throw new Error(`File not found: ${relativePath}`);
|
|
@@ -111,6 +177,7 @@ export async function saveProjectFile(
|
|
|
111
177
|
relativePath: string,
|
|
112
178
|
content: string,
|
|
113
179
|
): Promise<void> {
|
|
180
|
+
assertNotDotenv(relativePath);
|
|
114
181
|
const absPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
115
182
|
if (!absPath) throw new Error(`Access denied: ${relativePath}`);
|
|
116
183
|
await mkdir(path.dirname(absPath), { recursive: true });
|
|
@@ -122,17 +189,34 @@ export async function saveProjectFile(
|
|
|
122
189
|
export async function listEnvFiles(
|
|
123
190
|
projectRoot: string,
|
|
124
191
|
): Promise<readonly { name: string; exists: boolean }[]> {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
const resolved = await resolveAllowedPath(projectRoot, name);
|
|
192
|
+
const result = await Promise.all(ENV_FILE_NAMES.map(async (name) => {
|
|
193
|
+
const resolved = await resolveContainedPath(projectRoot, name, isEnvInventoryTarget);
|
|
128
194
|
return { name, exists: resolved !== null && await pathExists(resolved) };
|
|
129
195
|
}));
|
|
130
196
|
return Object.freeze(result);
|
|
131
197
|
}
|
|
132
198
|
|
|
199
|
+
function assertNotDotenv(relativePath: string): void {
|
|
200
|
+
if (isDotenvProjectPath(relativePath)) throw new Error(DOTENV_ACCESS_DENIED_MESSAGE);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The env inventory only counts a dotenv file (or link) that stays inside the project and off blocked paths. */
|
|
204
|
+
function isEnvInventoryTarget(relativePath: string): boolean {
|
|
205
|
+
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) return false;
|
|
206
|
+
return !relativePath.replace(/\\/gu, '/').split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveAllowedPath(projectRoot: string, relativePath: string): Promise<string | null> {
|
|
210
|
+
return resolveContainedPath(projectRoot, relativePath, isProjectPathAllowed);
|
|
211
|
+
}
|
|
212
|
+
|
|
133
213
|
/** Resolve existing links, or the nearest existing parent for a new file. */
|
|
134
|
-
async function
|
|
135
|
-
|
|
214
|
+
async function resolveContainedPath(
|
|
215
|
+
projectRoot: string,
|
|
216
|
+
relativePath: string,
|
|
217
|
+
allowed: (relativePath: string) => boolean,
|
|
218
|
+
): Promise<string | null> {
|
|
219
|
+
if (!allowed(relativePath)) return null;
|
|
136
220
|
try {
|
|
137
221
|
const root = await realpath(projectRoot);
|
|
138
222
|
let candidate = path.resolve(root, relativePath);
|
|
@@ -143,7 +227,7 @@ async function resolveAllowedPath(projectRoot: string, relativePath: string): Pr
|
|
|
143
227
|
await lstat(candidate);
|
|
144
228
|
const resolved = path.resolve(await realpath(candidate), ...missing);
|
|
145
229
|
const relative = path.relative(root, resolved);
|
|
146
|
-
return
|
|
230
|
+
return allowed(relative) ? resolved : null;
|
|
147
231
|
} catch (error) {
|
|
148
232
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return null;
|
|
149
233
|
// lstat succeeds for dangling symlinks even though realpath fails.
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
|
|
8
|
+
const logger = getLogger('console-rpc');
|
|
9
|
+
const WINDOW_MS = 10 * 60_000;
|
|
10
|
+
const MAX_TYPES = 256;
|
|
11
|
+
|
|
12
|
+
interface Seen { at: number; suppressed: number }
|
|
13
|
+
|
|
14
|
+
export interface UnknownRpcLog {
|
|
15
|
+
/** Records one unknown RPC; returns true when it was logged (not suppressed). */
|
|
16
|
+
note(type: unknown, now?: number): boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type UnknownRpcLogEntry = Readonly<{ op: 'console_rpc_unknown'; type: string; repeated?: number }>;
|
|
20
|
+
|
|
21
|
+
export function createUnknownRpcLog(log: (entry: UnknownRpcLogEntry) => void = entry => logger.debug(formatCompact({ ...entry }))): UnknownRpcLog {
|
|
22
|
+
const seen = new Map<string, Seen>();
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
note(type: unknown, now = Date.now()): boolean {
|
|
25
|
+
const name = String(type ?? '').slice(0, 64) || '<empty>';
|
|
26
|
+
const entry = seen.get(name);
|
|
27
|
+
if (entry && now - entry.at < WINDOW_MS) {
|
|
28
|
+
entry.suppressed += 1;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (!entry && seen.size >= MAX_TYPES) {
|
|
32
|
+
const oldest = seen.keys().next().value;
|
|
33
|
+
if (oldest !== undefined) seen.delete(oldest);
|
|
34
|
+
}
|
|
35
|
+
seen.delete(name);
|
|
36
|
+
seen.set(name, { at: now, suppressed: 0 });
|
|
37
|
+
log({ op: 'console_rpc_unknown', type: name, ...(entry?.suppressed ? { repeated: entry.suppressed } : {}) });
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Process-wide log shared by the Host's RPC routes. */
|
|
44
|
+
export const unknownRpcLog: UnknownRpcLog = createUnknownRpcLog();
|