@zhin.js/gateway 1.2.0 → 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 (61) hide show
  1. package/README.md +1 -1
  2. package/lib/config/configuration.d.ts +32 -3
  3. package/lib/config/configuration.js +120 -25
  4. package/lib/config/environment-files.d.ts +42 -0
  5. package/lib/config/environment-files.js +347 -7
  6. package/lib/config/literal-secrets.d.ts +3 -0
  7. package/lib/config/literal-secrets.js +4 -1
  8. package/lib/config/patch-path-messages.d.ts +15 -0
  9. package/lib/config/patch-path-messages.js +71 -0
  10. package/lib/config/secret-storage.d.ts +42 -0
  11. package/lib/config/secret-storage.js +140 -0
  12. package/lib/config/validation-messages.d.ts +15 -0
  13. package/lib/config/validation-messages.js +66 -0
  14. package/lib/config.d.ts +1 -0
  15. package/lib/config.js +1 -0
  16. package/lib/database/console-role.js +2 -1
  17. package/lib/http/console-rpc.d.ts +3 -1
  18. package/lib/http/console-rpc.js +31 -4
  19. package/lib/http/gateway-console.d.ts +5 -0
  20. package/lib/http/gateway-console.js +31 -5
  21. package/lib/http/gateway-http-server.d.ts +2 -0
  22. package/lib/http/gateway-http-server.js +42 -1
  23. package/lib/http/local-principal.d.ts +1 -1
  24. package/lib/http/local-principal.js +3 -2
  25. package/lib/http/plugin-switch-rpc.js +7 -6
  26. package/lib/http/start-gateway.d.ts +1 -1
  27. package/lib/http/start-gateway.js +2 -0
  28. package/lib/iam/service.js +12 -0
  29. package/lib/iam/setup-service.d.ts +3 -1
  30. package/lib/iam/setup-service.js +2 -1
  31. package/lib/onboarding/ignored-builds.d.ts +18 -0
  32. package/lib/onboarding/ignored-builds.js +63 -0
  33. package/lib/onboarding/index.d.ts +1 -0
  34. package/lib/onboarding/index.js +1 -0
  35. package/lib/packages/errors.js +1 -0
  36. package/lib/packages/rpc.d.ts +6 -1
  37. package/lib/packages/rpc.js +28 -2
  38. package/lib/packages/service.d.ts +3 -1
  39. package/lib/packages/service.js +74 -9
  40. package/lib/plugin-management/plugin-candidates.d.ts +9 -0
  41. package/lib/plugin-management/plugin-candidates.js +55 -0
  42. package/lib/plugin-management/use-cases.js +8 -8
  43. package/lib/plugin-management/workflow.d.ts +13 -2
  44. package/lib/plugin-management/workflow.js +46 -13
  45. package/lib/plugin-management.d.ts +1 -0
  46. package/lib/plugin-management.js +1 -0
  47. package/lib/project/identity.d.ts +2 -0
  48. package/lib/project/identity.js +21 -0
  49. package/lib/project.d.ts +1 -0
  50. package/lib/project.js +1 -0
  51. package/lib/supervisor/instance-supervisor.d.ts +4 -1
  52. package/lib/supervisor/instance-supervisor.js +18 -1
  53. package/lib/terminal/modules.d.ts +12 -0
  54. package/lib/terminal/modules.js +38 -2
  55. package/lib/usage/aggregate.d.ts +64 -3
  56. package/lib/usage/aggregate.js +199 -26
  57. package/lib/usage/prompt-cache.d.ts +49 -0
  58. package/lib/usage/prompt-cache.js +113 -0
  59. package/lib/usage.d.ts +1 -0
  60. package/lib/usage.js +1 -0
  61. package/package.json +8 -8
package/README.md CHANGED
@@ -29,7 +29,7 @@ zhin console init # 首次创建 Console 所有者;之后 http.token
29
29
  |---|---|
30
30
  | `@zhin.js/gateway/iam` | Console 账号、角色、会话、API 令牌、审计与初始化(ADR 0049):`ConsoleIamService`、`ConsoleSetupService`、`installConsoleIam`、`registerConsoleAuthRoutes`、`dispatchIamRpc`、`importLegacyIamTables` / `revertLegacyIamImport` / `inspectLegacyIamImport`(旧版项目库账号表迁入 gateway.db 与回退,ADR 0059) |
31
31
  | `@zhin.js/gateway/terminal` | Console 终端(ADR 0051):`TerminalService`、`installHostConsoleTerminal`、`parseTerminalConfig` |
32
- | `@zhin.js/gateway/config` | 原始配置文件与 `.env` 读写:`ConsoleConfigurationStore`、`readEnvFile` / `writeEnvFile`、配置路径与补丁、Console Schema |
32
+ | `@zhin.js/gateway/config` | 原始配置文件与 `.env` 读写:`ConsoleConfigurationStore`、`readEnvFile` / `writeEnvFile`(Host 内部用)、Console 用的打码视图与逐键保存(`readEnvFileView` / `revealEnvVariable` / `updateEnvVariables` / `replaceEnvFileContent`)、配置路径与补丁、Console Schema |
33
33
  | `@zhin.js/gateway/plugin-management` | 项目级依赖事务:`PluginManagementWorkflow`、`PluginManagementActivity`、插件用例 |
34
34
  | `@zhin.js/gateway/project` | 项目文件工具:`findConfigFile`、`loadRuntimeEnvironmentLayers`、`createTsxModuleLoader`、实例声明迁移、旧配置检测、Node 版本要求 |
35
35
  | `@zhin.js/gateway/supervisor` | 实例进程监管 `InstanceSupervisor`:IPC 控制通道(`ready` / `shutdown` / `restart-request`)、崩溃重启与风暴保护、日志采集(环形缓冲 + `.zhin/logs/instance.log` 按大小轮转);`runSupervisedInstance` 供 `zhin start` 使用 |
@@ -2,6 +2,7 @@ import { type ConsoleConfigApplication, type ConsoleConfigApplicationStatus, typ
2
2
  import type { PluginConfigValidation } from '@zhin.js/host-http';
3
3
  import { type RuntimeConfigDocument } from '@zhin.js/plugin-runtime';
4
4
  import { type ConfigFileDocument, type EnvStore } from '@zhin.js/runtime';
5
+ import type { LiteralConfigSecret } from '@zhin.js/runtime';
5
6
  export interface ChatConfigurationField {
6
7
  readonly path: readonly string[];
7
8
  readonly type: readonly string[];
@@ -19,6 +20,14 @@ export declare class ConfigChatRequestRejectedError extends Error {
19
20
  constructor();
20
21
  }
21
22
  /** Owns one project's Console configuration projection over the Root config authority. */
23
+ /**
24
+ * What the caller of a configuration write may do besides `ops.config.write`. Set by the Console
25
+ * RPC context from the principal's grants, and by trusted in-process / local-owner callers.
26
+ */
27
+ export interface ConfigWriteAuthority {
28
+ /** The caller may write `.env` (`ops.env.write`): secret literals may be moved there. Default false. */
29
+ readonly environmentWrite?: boolean;
30
+ }
22
31
  export declare class ConsoleConfigurationStore {
23
32
  #private;
24
33
  constructor(options: {
@@ -32,12 +41,22 @@ export declare class ConsoleConfigurationStore {
32
41
  readonly validateConfiguration?: (document: RuntimeConfigDocument) => Promise<void>;
33
42
  });
34
43
  readSource(): Promise<ConsoleConfigSource>;
44
+ /**
45
+ * Secret fields of the stored (unexpanded) configuration that hold literals instead of
46
+ * `${VAR}` references (ADR 0057). Paths only, never values.
47
+ */
48
+ readLiteralSecrets(): Promise<readonly LiteralConfigSecret[]>;
35
49
  readDocument(): Promise<Record<string, unknown>>;
36
50
  replaceSource(source: string, expectedRevision: string): Promise<{
37
51
  readonly revision: string;
38
52
  readonly restartRequired: boolean;
39
53
  } & ConsoleConfigApplicationStatus>;
40
- setKey(pluginName: string, data: unknown): Promise<{
54
+ /**
55
+ * `options.environmentWrite`: the caller holds `ops.env.write` (or is a trusted in-process / local
56
+ * owner caller). Without it a block whose secret fields hold literals is refused
57
+ * ({@link SecretEnvironmentWriteRequiredError}): those values would be written into `.env`.
58
+ */
59
+ setKey(pluginName: string, data: unknown, options?: ConfigWriteAuthority): Promise<{
41
60
  restartRequired: boolean;
42
61
  } & ConsoleConfigApplicationStatus>;
43
62
  removeKey(pluginName: string): Promise<{
@@ -48,14 +67,14 @@ export declare class ConsoleConfigurationStore {
48
67
  * allowlist are rejected before reading values; a stale revision returns the latest
49
68
  * values instead of overwriting; every touched section is validated by its schema.
50
69
  */
51
- patch(request: ConsoleConfigPatchRequest): Promise<ConsoleConfigPatchResult>;
70
+ patch(request: ConsoleConfigPatchRequest, options?: ConfigWriteAuthority): Promise<ConsoleConfigPatchResult>;
52
71
  /**
53
72
  * Trusted project administration (`config:patch-project`, full scope only). Same
54
73
  * single-CAS core as {@link patch}, without the page allowlist; it does not widen
55
74
  * `config:patch` / `HOST_CONFIG_PATCH_PATHS`. Declaration writes are rejected as
56
75
  * `invalid`, conflict values are masked, and a thrown commit is `unconfirmed`.
57
76
  */
58
- patchProject(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>): Promise<ConsoleConfigProjectPatchResult>;
77
+ patchProject(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>, options?: ConfigWriteAuthority): Promise<ConsoleConfigProjectPatchResult>;
59
78
  /** Host-authenticated owner chat. Policy checks share the mutation's CAS boundary. */
60
79
  patchChat(request: Omit<ConsoleConfigPatchRequest, 'instanceKey'>): Promise<ConsoleConfigPatchResult>;
61
80
  /**
@@ -73,6 +92,16 @@ export declare class ConsoleConfigurationStore {
73
92
  apply(expectedRevision: string): Promise<ConsoleConfigApplyResult>;
74
93
  readEnvironmentFile(filename: string): Promise<string>;
75
94
  writeEnvironmentFile(filename: string, content: string): Promise<void>;
95
+ /** Masked view for the Console (`env:get`); never a value. */
96
+ readEnvironmentView(filename: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvFileView>;
97
+ /** One variable as written (`env:reveal`); the caller authorizes and audits. */
98
+ revealEnvironmentVariable(filename: string, key: string): Promise<import('@zhin.js/console-protocol').ConsoleEnvRevealResult>;
99
+ /** Per-key save (`env:save { changes }`); keys not named keep their lines. */
100
+ updateEnvironmentVariables(filename: string, changes: unknown): Promise<{
101
+ readonly changed: readonly string[];
102
+ }>;
103
+ /** Whole-file save (`env:save { content, mode: 'replace' }`); refused when it holds mask placeholders. */
104
+ replaceEnvironmentFile(filename: string, content: string): Promise<void>;
76
105
  readSchema(pluginName?: string): Promise<unknown>;
77
106
  validatePluginConfig(pluginName: string, data: unknown, capturedDocument?: RuntimeConfigDocument): Promise<PluginConfigValidation>;
78
107
  readAllSchemas(): Promise<Record<string, unknown>>;
@@ -5,11 +5,15 @@ import { ConfigDocumentConflictError, ConfigValidationError, ConfigurationSupers
5
5
  import { loadRuntimeEnvironmentLayers } from '../project/environment.js';
6
6
  import { readPluginPackageMap } from './plugin-package-map.js';
7
7
  import { assertUnambiguousConfigKey, configKeyPatch, configKeyRemovePatch, flattenConfigDocument } from './configuration-projection.js';
8
- import { readEnvFile, writeEnvFile } from './environment-files.js';
8
+ import { readEnvFile, readEnvFileView, replaceEnvFileContent, revealEnvVariable, updateEnvVariables, writeEnvFile, } from './environment-files.js';
9
9
  import { PluginSchemaCatalog } from './plugin-schema-catalog.js';
10
+ import { declarationKeyMessage, describeArrayPathProblem, unknownConfigKeyMessage } from './patch-path-messages.js';
11
+ import { findProjectLiteralSecrets } from './literal-secrets.js';
12
+ import { planSecretExternalization, SECRET_ENVIRONMENT_WRITE_REQUIRED, SecretEnvironmentWriteRequiredError, secretEnvironmentWriteRequiredMessage, writeSecretAssignments, } from './secret-storage.js';
10
13
  import { hostModelOwnerId } from '@zhin.js/plugin-runtime';
11
14
  import { assertChatConfigurationAccess, assertChatConfigurationScope, assertChatValueHasNoEnvironmentReference, assertChatOptionalLeaf, chatConfigurationLeaves, chatHostRoots, chatLeafTypes, ConfigChatAccessError, } from './configuration-chat-policy.js';
12
15
  import { isForbiddenConfigurationPathSegment } from './configuration-path.js';
16
+ import { formatSchemaIssue } from './validation-messages.js';
13
17
  import { assertPatchesInScope, normalizeConfigPatchOperations, readDeclaredPatchPaths, readPath, resolvePatchScope, } from './configuration-patch.js';
14
18
  import { maskConfigValue, REDACTED_CONFIG_VALUE } from './configuration-redaction.js';
15
19
  /** A Host-classified rejection before this operation attempted any commit. */
@@ -47,7 +51,6 @@ function chatApplication(application) {
47
51
  dependsOn: Object.freeze([]),
48
52
  }))) });
49
53
  }
50
- /** Owns one project's Console configuration projection over the Root config authority. */
51
54
  export class ConsoleConfigurationStore {
52
55
  #projectRoot;
53
56
  #document;
@@ -93,6 +96,13 @@ export class ConsoleConfigurationStore {
93
96
  });
94
97
  });
95
98
  }
99
+ /**
100
+ * Secret fields of the stored (unexpanded) configuration that hold literals instead of
101
+ * `${VAR}` references (ADR 0057). Paths only, never values.
102
+ */
103
+ readLiteralSecrets() {
104
+ return this.#afterMutations(async () => findProjectLiteralSecrets(this.#projectRoot, (await this.#document.read()).document));
105
+ }
96
106
  readDocument() {
97
107
  return this.#afterMutations(async () => {
98
108
  const snapshot = await this.#document.read();
@@ -112,16 +122,36 @@ export class ConsoleConfigurationStore {
112
122
  });
113
123
  });
114
124
  }
115
- setKey(pluginName, data) {
125
+ /**
126
+ * `options.environmentWrite`: the caller holds `ops.env.write` (or is a trusted in-process / local
127
+ * owner caller). Without it a block whose secret fields hold literals is refused
128
+ * ({@link SecretEnvironmentWriteRequiredError}): those values would be written into `.env`.
129
+ */
130
+ setKey(pluginName, data, options = {}) {
116
131
  return this.#serialize(async () => {
117
132
  const current = await this.#document.read();
118
133
  await this.#assertUnambiguousKey(current.document, pluginName);
119
- const prepared = await this.#document.prepare(current, [
120
- configKeyPatch(current.document, pluginName, data),
121
- ]);
122
- readApplicationPluginDeclaration(prepared.document);
123
- await this.#validatePrepared(prepared);
124
- const committed = await prepared.commit();
134
+ const patches = [configKeyPatch(current.document, pluginName, data)];
135
+ // Literal values of secret fields go to `.env`; the block keeps `${VAR}` references (#1250).
136
+ const plan = await planSecretExternalization(this.#projectRoot, this.#environmentName(), current.document, applyConfigPatches(current.document, patches), patches.map(patch => patch.path));
137
+ if (plan.assignments.length > 0 && options.environmentWrite !== true) {
138
+ throw new SecretEnvironmentWriteRequiredError(plan.assignments.map(item => item.path));
139
+ }
140
+ if (plan.errors.length > 0)
141
+ throw new Error(plan.errors.map(error => error.message).join(';'));
142
+ patches.push(...plan.patches);
143
+ const restoreEnvironment = await writeSecretAssignments(this.#projectRoot, this.#environmentName(), plan.assignments);
144
+ let committed;
145
+ try {
146
+ const prepared = await this.#document.prepare(current, patches);
147
+ readApplicationPluginDeclaration(prepared.document);
148
+ await this.#validatePrepared(prepared);
149
+ committed = await prepared.commit();
150
+ }
151
+ catch (error) {
152
+ await restoreEnvironment();
153
+ throw error;
154
+ }
125
155
  return this.#applySaved(HOST_CONFIG_KEYS.includes(pluginName), committed.revision);
126
156
  });
127
157
  }
@@ -143,8 +173,8 @@ export class ConsoleConfigurationStore {
143
173
  * allowlist are rejected before reading values; a stale revision returns the latest
144
174
  * values instead of overwriting; every touched section is validated by its schema.
145
175
  */
146
- patch(request) {
147
- return this.#serialize(() => this.#patchCore(request, 'scoped'));
176
+ patch(request, options = {}) {
177
+ return this.#serialize(() => this.#patchCore(request, 'scoped', options));
148
178
  }
149
179
  /**
150
180
  * Trusted project administration (`config:patch-project`, full scope only). Same
@@ -152,10 +182,11 @@ export class ConsoleConfigurationStore {
152
182
  * `config:patch` / `HOST_CONFIG_PATCH_PATHS`. Declaration writes are rejected as
153
183
  * `invalid`, conflict values are masked, and a thrown commit is `unconfirmed`.
154
184
  */
155
- patchProject(request) {
185
+ patchProject(request, options = {}) {
156
186
  return this.#serialize(async () => {
157
187
  try {
158
- return await this.#patchCore({ expectedRevision: request.expectedRevision, patches: request.patches }, 'project');
188
+ return await this.#patchCore({ expectedRevision: request.expectedRevision, patches: request.patches,
189
+ ...(request.secretStorage === undefined ? {} : { secretStorage: request.secretStorage }) }, 'project', options);
159
190
  }
160
191
  catch (error) {
161
192
  if (!(error instanceof ConfigCommitUnconfirmedError))
@@ -187,11 +218,13 @@ export class ConsoleConfigurationStore {
187
218
  });
188
219
  }
189
220
  /** Shared single-CAS write: `scoped` keeps the page allowlist; `project` is the trusted full path. */
190
- async #patchCore(request, mode) {
221
+ async #patchCore(request, mode, authority = {}) {
191
222
  let phase = 'prepare';
223
+ // Restores `.env` when secrets were moved there but the config write does not happen.
224
+ let restoreEnvironment;
192
225
  try {
193
226
  const instanceKey = request.instanceKey;
194
- const patches = normalizeConfigPatchOperations(request.patches);
227
+ let patches = normalizeConfigPatchOperations(request.patches);
195
228
  const current = await this.#document.read();
196
229
  if (mode === 'scoped') {
197
230
  const declared = instanceKey === undefined
@@ -204,8 +237,9 @@ export class ConsoleConfigurationStore {
204
237
  if (mode !== 'scoped') {
205
238
  const rejected = projectDeclarationErrors(current.document, paths);
206
239
  if (rejected.length > 0) {
240
+ const declarationWrite = paths.some(path => PROJECT_PATCH_DECLARATION_KEYS.has(path[0]));
207
241
  return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(rejected),
208
- missingEnv: Object.freeze([]), message: '插件声明不能通过项目补丁修改,未保存' });
242
+ missingEnv: Object.freeze([]), message: declarationWrite ? '插件声明不能通过项目补丁修改,未保存' : '没有这个配置项,未保存' });
209
243
  }
210
244
  }
211
245
  const conflict = async (snapshot) => Object.freeze({
@@ -217,7 +251,44 @@ export class ConsoleConfigurationStore {
217
251
  });
218
252
  if (current.revision !== request.expectedRevision)
219
253
  return await conflict(current);
220
- const candidate = applyConfigPatches(current.document, patches);
254
+ // Array indexes outside the array (or a key where an index belongs) are a usage problem:
255
+ // answer with an explanation instead of the document layer's internal exception.
256
+ const arrayProblems = patches.flatMap(patch => {
257
+ const message = describeArrayPathProblem(current.document, patch.path, patch.op);
258
+ return message ? [{ path: `$.${patch.path.join('.')}`, message }] : [];
259
+ });
260
+ if (arrayProblems.length > 0) {
261
+ return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(arrayProblems),
262
+ missingEnv: Object.freeze([]), message: '配置路径无效,未保存' });
263
+ }
264
+ let candidate = applyConfigPatches(current.document, patches);
265
+ // Secret fields keep `${VAR}` references: a literal written here goes to `.env` (#1250),
266
+ // unless the caller explicitly asked for plain storage. Chat writes never carry literals
267
+ // into secret fields (they are refused by the chat policy).
268
+ let envSecrets = [];
269
+ let secretAssignments = [];
270
+ if (mode !== 'chat' && request.secretStorage !== 'plain') {
271
+ const plan = await planSecretExternalization(this.#projectRoot, this.#environmentName(), current.document, candidate, patches.filter(patch => patch.op === 'set').map(patch => patch.path));
272
+ if (plan.errors.length > 0) {
273
+ return Object.freeze({ status: 'invalid', revision: current.revision, errors: Object.freeze(plan.errors),
274
+ missingEnv: Object.freeze([]), message: '配置校验失败,未保存' });
275
+ }
276
+ // Moving a value into `.env` is an `.env` write: it needs `ops.env.write` (#1250). Without
277
+ // it the write is refused, never stored as plain text behind the caller's back.
278
+ if (plan.assignments.length > 0 && authority.environmentWrite !== true) {
279
+ const paths = plan.assignments.map(item => item.path);
280
+ return Object.freeze({ status: 'invalid', revision: current.revision, code: SECRET_ENVIRONMENT_WRITE_REQUIRED,
281
+ errors: Object.freeze(paths.map(path => Object.freeze({ path: `$.${path.join('.')}`,
282
+ message: '敏感字段的值要写入 .env,需要 ops.env.write 权限' }))),
283
+ missingEnv: Object.freeze([]), message: secretEnvironmentWriteRequiredMessage(paths) });
284
+ }
285
+ if (plan.patches.length > 0) {
286
+ patches = Object.freeze([...patches, ...plan.patches]);
287
+ candidate = applyConfigPatches(current.document, patches);
288
+ secretAssignments = plan.assignments;
289
+ envSecrets = Object.freeze(plan.assignments.map(item => Object.freeze({ path: item.path, variable: item.variable })));
290
+ }
291
+ }
221
292
  // Chat writes exactly one leaf (C1/C4); scope and leaf shape are proven before values.
222
293
  if (mode === 'chat' && patches.length !== 1)
223
294
  throw new ConfigChatAccessError('leaf');
@@ -258,6 +329,8 @@ export class ConsoleConfigurationStore {
258
329
  for (const name of validation.missingEnv)
259
330
  missingEnv.add(name);
260
331
  }
332
+ for (const { variable } of envSecrets)
333
+ missingEnv.delete(variable);
261
334
  if (errors.length > 0) {
262
335
  return Object.freeze({
263
336
  status: 'invalid',
@@ -268,6 +341,7 @@ export class ConsoleConfigurationStore {
268
341
  });
269
342
  }
270
343
  let committed;
344
+ restoreEnvironment = await writeSecretAssignments(this.#projectRoot, this.#environmentName(), secretAssignments);
271
345
  try {
272
346
  const prepared = await this.#document.prepare(current, patches);
273
347
  readApplicationPluginDeclaration(prepared.document);
@@ -280,6 +354,7 @@ export class ConsoleConfigurationStore {
280
354
  // Runtime validation may report cross-field constraints without a
281
355
  // structured path. Preserve the invalid contract at the document
282
356
  // boundary; arbitrary issue strings can contain private values.
357
+ await restoreEnvironment();
283
358
  return Object.freeze({ status: 'invalid', revision: current.revision,
284
359
  errors: Object.freeze([Object.freeze({ path: '$', message: '完整配置未通过运行校验' })]),
285
360
  missingEnv: Object.freeze([...missingEnv].sort()), message: '配置校验失败,未保存' });
@@ -301,6 +376,8 @@ export class ConsoleConfigurationStore {
301
376
  }
302
377
  }
303
378
  catch (error) {
379
+ if (phase === 'prepare')
380
+ await restoreEnvironment();
304
381
  if (error instanceof ConfigDocumentConflictError)
305
382
  return await conflict(await this.#document.read());
306
383
  throw error;
@@ -313,6 +390,7 @@ export class ConsoleConfigurationStore {
313
390
  ...applied,
314
391
  paths,
315
392
  missingEnv: Object.freeze([...missingEnv].sort()),
393
+ ...(envSecrets.length > 0 ? { envSecrets } : {}),
316
394
  message: applied.applicationError === 'superseded' ? '配置已保存,但该次生效申请已被替代'
317
395
  : applied.applicationError === 'invalid' ? '配置已保存,但配置校验失败,未生效'
318
396
  : applied.applicationError ? '配置已保存,生效结果未确认' : applied.restartRequired
@@ -551,6 +629,22 @@ export class ConsoleConfigurationStore {
551
629
  writeEnvironmentFile(filename, content) {
552
630
  return writeEnvFile(this.#projectRoot, filename, content);
553
631
  }
632
+ /** Masked view for the Console (`env:get`); never a value. */
633
+ readEnvironmentView(filename) {
634
+ return readEnvFileView(this.#projectRoot, filename);
635
+ }
636
+ /** One variable as written (`env:reveal`); the caller authorizes and audits. */
637
+ revealEnvironmentVariable(filename, key) {
638
+ return revealEnvVariable(this.#projectRoot, filename, key);
639
+ }
640
+ /** Per-key save (`env:save { changes }`); keys not named keep their lines. */
641
+ updateEnvironmentVariables(filename, changes) {
642
+ return updateEnvVariables(this.#projectRoot, filename, changes);
643
+ }
644
+ /** Whole-file save (`env:save { content, mode: 'replace' }`); refused when it holds mask placeholders. */
645
+ replaceEnvironmentFile(filename, content) {
646
+ return replaceEnvFileContent(this.#projectRoot, filename, content);
647
+ }
554
648
  async readSchema(pluginName) {
555
649
  const { document } = await this.#document.read();
556
650
  if (pluginName && !HOST_CONFIG_KEYS.includes(pluginName)
@@ -597,15 +691,15 @@ export class ConsoleConfigurationStore {
597
691
  for (const issue of candidate.errors) {
598
692
  const location = issue.instancePath.replace(/^\/candidate/u, '').split('/').slice(1)
599
693
  .map(key => key.replaceAll('~1', '/').replaceAll('~0', '~'));
694
+ // The message names the field relative to the block, in the project locale (`locale`).
695
+ const message = formatSchemaIssue(issue, location, document.locale);
600
696
  if (issue.keyword === 'required' && typeof issue.params.missingProperty === 'string') {
601
697
  location.push(issue.params.missingProperty);
602
698
  }
603
- errors.push({
604
- path: `$${location.map(key => `.${key}`).join('')}`,
605
- message: issue.keyword === 'type'
606
- ? `类型应为 ${issue.params.type === 'array' ? 'list' : String(issue.params.type)}`
607
- : issue.message ?? issue.keyword,
608
- });
699
+ if (issue.keyword === 'additionalProperties' && typeof issue.params.additionalProperty === 'string') {
700
+ location.push(issue.params.additionalProperty);
701
+ }
702
+ errors.push({ path: `$${location.map(key => `.${key}`).join('')}`, message });
609
703
  }
610
704
  }
611
705
  collectEnvironmentReferences(candidate.value, missingEnv, environment);
@@ -663,10 +757,11 @@ function projectDeclarationErrors(document, paths) {
663
757
  const errors = [];
664
758
  for (const section of new Set(paths.map(path => path[0]))) {
665
759
  if (PROJECT_PATCH_DECLARATION_KEYS.has(section)) {
666
- errors.push({ path: `$.${section}`, message: '插件声明与根插件配置请用 gateway.plugins.declare、配置源码或插件管理修改' });
760
+ errors.push({ path: `$.${section}`, message: declarationKeyMessage(section) });
667
761
  }
668
762
  else if (!HOST_CONFIG_KEYS.includes(section) && !declared.has(section)) {
669
- errors.push({ path: `$.${section}`, message: '未声明的插件实例;请先用 gateway.plugins.declare 声明' });
763
+ const known = [...HOST_CONFIG_KEYS, ...declared].filter(key => !PROJECT_PATCH_DECLARATION_KEYS.has(key));
764
+ errors.push({ path: `$.${section}`, message: unknownConfigKeyMessage(section, known) });
670
765
  }
671
766
  }
672
767
  return errors;
@@ -1,3 +1,45 @@
1
+ import { type ConsoleEnvChange, type ConsoleEnvFileView, type ConsoleEnvRevealResult } from '@zhin.js/console-protocol';
2
+ /** The file as written. Only for the Host itself (setup, doctor); the Console reads {@link readEnvFileView}. */
1
3
  export declare function readEnvFile(projectRoot: string, filename: string): Promise<string>;
2
4
  /** Locked, atomic replacement that keeps the file mode (`0600` when new); ADR 0057 §4. */
3
5
  export declare function writeEnvFile(projectRoot: string, filename: string, content: string): Promise<void>;
6
+ /**
7
+ * Masked view of `content` (`env:get`): exactly the variables dotenv reads, each masked; comments
8
+ * that carry a value are masked too. Never contains a value, its length, or a line's length.
9
+ */
10
+ export declare function maskEnvFileContent(file: string, content: string): ConsoleEnvFileView;
11
+ export declare function readEnvFileView(projectRoot: string, filename: string): Promise<ConsoleEnvFileView>;
12
+ export declare class EnvVariableNotFoundError extends Error {
13
+ readonly code = "env_variable_not_found";
14
+ constructor(filename: string, key: string);
15
+ }
16
+ /**
17
+ * One variable (its effective, i.e. last, assignment) as written in the file: exactly the text
18
+ * dotenv reads as that variable's value, so it never carries another line of the file.
19
+ */
20
+ export declare function revealEnvVariable(projectRoot: string, filename: string, key: string): Promise<ConsoleEnvRevealResult>;
21
+ export declare class EnvChangeError extends Error {
22
+ readonly code = "invalid_env_change";
23
+ constructor(message: string);
24
+ }
25
+ /** Validated `env:save` changes; throws {@link EnvChangeError} (nothing is written). */
26
+ export declare function validateEnvChanges(changes: unknown): readonly ConsoleEnvChange[];
27
+ /**
28
+ * Applies validated changes to `content`; every line not naming a changed key keeps its bytes.
29
+ * Throws {@link EnvChangeError} when dotenv would read anything but the submitted changes.
30
+ */
31
+ export declare function applyEnvChanges(content: string, changes: readonly ConsoleEnvChange[]): string;
32
+ /**
33
+ * `env:save { changes }`: applies the changes to the file as it is on disk, inside the dotenv
34
+ * lock. Keys not named keep their lines byte for byte (they are never rebuilt from a masked view).
35
+ */
36
+ export declare function updateEnvVariables(projectRoot: string, filename: string, changes: unknown): Promise<{
37
+ readonly changed: readonly string[];
38
+ }>;
39
+ /** Keys of `content` whose value (or commented value) is a mask placeholder. */
40
+ export declare function maskedPlaceholderKeys(content: string): readonly string[];
41
+ /**
42
+ * `env:save { content, mode: 'replace' }`: the whole-file form. Refused when any value is a mask
43
+ * placeholder, so a masked view can never be written back over the real values.
44
+ */
45
+ export declare function replaceEnvFileContent(projectRoot: string, filename: string, content: string): Promise<void>;