@zhin.js/host-http 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/lib/access-control.d.ts +41 -3
  2. package/lib/access-control.js +112 -10
  3. package/lib/console-rest-pages-helpers.js +24 -2
  4. package/lib/console-rest-pages.d.ts +4 -1
  5. package/lib/console-rest-pages.js +49 -23
  6. package/lib/console-rpc-extended/contracts.d.ts +22 -0
  7. package/lib/console-rpc-extended/dispatcher.js +1 -1
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +1 -1
  10. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  11. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  12. package/lib/console-rpc-handlers.d.ts +9 -1
  13. package/lib/console-rpc-handlers.js +108 -12
  14. package/lib/console-rpc-types.d.ts +2 -0
  15. package/lib/console-rpc-types.js +43 -0
  16. package/lib/console-rpc.d.ts +29 -1
  17. package/lib/console-rpc.js +13 -5
  18. package/lib/host-installer.js +10 -5
  19. package/lib/http-host.js +5 -4
  20. package/lib/index.d.ts +6 -3
  21. package/lib/index.js +4 -1
  22. package/lib/node/listener.js +2 -1
  23. package/lib/port.d.ts +27 -0
  24. package/lib/port.js +68 -0
  25. package/lib/project-files.d.ts +7 -0
  26. package/lib/project-files.js +88 -11
  27. package/lib/unknown-rpc.d.ts +12 -0
  28. package/lib/unknown-rpc.js +33 -0
  29. package/package.json +4 -4
  30. package/src/access-control.ts +151 -11
  31. package/src/console-rest-pages-helpers.ts +27 -3
  32. package/src/console-rest-pages.ts +52 -15
  33. package/src/console-rpc-extended/contracts.ts +18 -0
  34. package/src/console-rpc-extended/dispatcher.ts +1 -1
  35. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  36. package/src/console-rpc-extended/index.ts +1 -0
  37. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  38. package/src/console-rpc-handlers.ts +110 -11
  39. package/src/console-rpc-types.ts +45 -0
  40. package/src/console-rpc.ts +39 -5
  41. package/src/host-installer.ts +10 -4
  42. package/src/http-host.ts +6 -2
  43. package/src/index.ts +21 -0
  44. package/src/node/listener.ts +2 -1
  45. package/src/port.ts +60 -0
  46. package/src/project-files.ts +95 -11
  47. package/src/unknown-rpc.ts +44 -0
@@ -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
+ }
@@ -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
  }
@@ -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
- port: typeof value.port === 'number' ? value.port : undefined,
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
- if (typeof listener.port !== 'number') {
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: listener.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,
@@ -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 ?? 8086;
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
+ }
@@ -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
- if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment))) return false;
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 names = ['.env', '.env.development', '.env.production'] as const;
126
- const result = await Promise.all(names.map(async (name) => {
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 resolveAllowedPath(projectRoot: string, relativePath: string): Promise<string | null> {
135
- if (!isProjectPathAllowed(relativePath)) return null;
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 isProjectPathAllowed(relative) ? resolved : null;
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();