@zhin.js/gateway 0.0.1-bootstrap.0 → 1.1.2

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/lib/config/ai-config-loader.d.ts +22 -0
  4. package/lib/config/ai-config-loader.js +96 -0
  5. package/lib/config/configuration-chat-policy.d.ts +57 -0
  6. package/lib/config/configuration-chat-policy.js +423 -0
  7. package/lib/config/configuration-patch.d.ts +16 -0
  8. package/lib/config/configuration-patch.js +105 -0
  9. package/lib/config/configuration-path.d.ts +6 -0
  10. package/lib/config/configuration-path.js +40 -0
  11. package/lib/config/configuration-projection.d.ts +6 -0
  12. package/lib/config/configuration-projection.js +28 -0
  13. package/lib/config/configuration-redaction.d.ts +18 -0
  14. package/lib/config/configuration-redaction.js +75 -0
  15. package/lib/config/configuration.d.ts +81 -0
  16. package/lib/config/configuration.js +686 -0
  17. package/lib/config/console-schema.d.ts +11 -0
  18. package/lib/config/console-schema.js +223 -0
  19. package/lib/config/environment-files.d.ts +3 -0
  20. package/lib/config/environment-files.js +14 -0
  21. package/lib/config/literal-secrets.d.ts +34 -0
  22. package/lib/config/literal-secrets.js +136 -0
  23. package/lib/config/plugin-package-map.d.ts +2 -0
  24. package/lib/config/plugin-package-map.js +14 -0
  25. package/lib/config/plugin-schema-catalog.d.ts +22 -0
  26. package/lib/config/plugin-schema-catalog.js +262 -0
  27. package/lib/config.d.ts +13 -0
  28. package/lib/config.js +13 -0
  29. package/lib/database/config.d.ts +22 -0
  30. package/lib/database/config.js +159 -0
  31. package/lib/database/index.d.ts +2 -0
  32. package/lib/database/index.js +2 -0
  33. package/lib/database/project-database.d.ts +31 -0
  34. package/lib/database/project-database.js +130 -0
  35. package/lib/database.d.ts +2 -0
  36. package/lib/database.js +2 -0
  37. package/lib/http/console-rpc.d.ts +27 -0
  38. package/lib/http/console-rpc.js +51 -0
  39. package/lib/http/gateway-console.d.ts +63 -0
  40. package/lib/http/gateway-console.js +309 -0
  41. package/lib/http/gateway-events.d.ts +40 -0
  42. package/lib/http/gateway-events.js +185 -0
  43. package/lib/http/gateway-http-server.d.ts +95 -0
  44. package/lib/http/gateway-http-server.js +522 -0
  45. package/lib/http/local-principal.d.ts +2 -0
  46. package/lib/http/local-principal.js +16 -0
  47. package/lib/http/plugin-switch-rpc.d.ts +19 -0
  48. package/lib/http/plugin-switch-rpc.js +214 -0
  49. package/lib/http/run-gateway.d.ts +43 -0
  50. package/lib/http/run-gateway.js +268 -0
  51. package/lib/http/start-gateway.d.ts +114 -0
  52. package/lib/http/start-gateway.js +226 -0
  53. package/lib/http-response.d.ts +6 -0
  54. package/lib/http-response.js +25 -0
  55. package/lib/http.d.ts +6 -0
  56. package/lib/http.js +6 -0
  57. package/lib/iam/crypto.d.ts +12 -0
  58. package/lib/iam/crypto.js +58 -0
  59. package/lib/iam/index.d.ts +8 -0
  60. package/lib/iam/index.js +8 -0
  61. package/lib/iam/install.d.ts +36 -0
  62. package/lib/iam/install.js +74 -0
  63. package/lib/iam/local-admin.d.ts +27 -0
  64. package/lib/iam/local-admin.js +102 -0
  65. package/lib/iam/rate-limit.d.ts +15 -0
  66. package/lib/iam/rate-limit.js +52 -0
  67. package/lib/iam/routes.d.ts +23 -0
  68. package/lib/iam/routes.js +157 -0
  69. package/lib/iam/rpc.d.ts +14 -0
  70. package/lib/iam/rpc.js +100 -0
  71. package/lib/iam/service.d.ts +203 -0
  72. package/lib/iam/service.js +784 -0
  73. package/lib/iam/setup-service.d.ts +83 -0
  74. package/lib/iam/setup-service.js +206 -0
  75. package/lib/iam/tables.d.ts +20 -0
  76. package/lib/iam/tables.js +82 -0
  77. package/lib/iam.d.ts +2 -0
  78. package/lib/iam.js +2 -0
  79. package/lib/index.d.ts +22 -0
  80. package/lib/index.js +22 -0
  81. package/lib/local/client.d.ts +71 -0
  82. package/lib/local/client.js +296 -0
  83. package/lib/local/handler.d.ts +30 -0
  84. package/lib/local/handler.js +86 -0
  85. package/lib/local/local-socket.d.ts +55 -0
  86. package/lib/local/local-socket.js +232 -0
  87. package/lib/local/paths.d.ts +34 -0
  88. package/lib/local/paths.js +58 -0
  89. package/lib/local/protocol.d.ts +140 -0
  90. package/lib/local/protocol.js +90 -0
  91. package/lib/local/state-file.d.ts +8 -0
  92. package/lib/local/state-file.js +45 -0
  93. package/lib/local.d.ts +10 -0
  94. package/lib/local.js +10 -0
  95. package/lib/onboarding/allowlist.d.ts +54 -0
  96. package/lib/onboarding/allowlist.js +106 -0
  97. package/lib/onboarding/errors.d.ts +7 -0
  98. package/lib/onboarding/errors.js +17 -0
  99. package/lib/onboarding/index.d.ts +6 -0
  100. package/lib/onboarding/index.js +6 -0
  101. package/lib/onboarding/install.d.ts +99 -0
  102. package/lib/onboarding/install.js +132 -0
  103. package/lib/onboarding/package-manager.d.ts +47 -0
  104. package/lib/onboarding/package-manager.js +179 -0
  105. package/lib/onboarding/service.d.ts +53 -0
  106. package/lib/onboarding/service.js +144 -0
  107. package/lib/onboarding/state-store.d.ts +26 -0
  108. package/lib/onboarding/state-store.js +97 -0
  109. package/lib/onboarding.d.ts +2 -0
  110. package/lib/onboarding.js +2 -0
  111. package/lib/packages/authorization.d.ts +24 -0
  112. package/lib/packages/authorization.js +128 -0
  113. package/lib/packages/errors.d.ts +11 -0
  114. package/lib/packages/errors.js +26 -0
  115. package/lib/packages/index.d.ts +5 -0
  116. package/lib/packages/index.js +5 -0
  117. package/lib/packages/rpc.d.ts +30 -0
  118. package/lib/packages/rpc.js +160 -0
  119. package/lib/packages/service.d.ts +139 -0
  120. package/lib/packages/service.js +504 -0
  121. package/lib/packages/snapshot.d.ts +14 -0
  122. package/lib/packages/snapshot.js +73 -0
  123. package/lib/packages.d.ts +2 -0
  124. package/lib/packages.js +2 -0
  125. package/lib/plugin-management/activity.d.ts +6 -0
  126. package/lib/plugin-management/activity.js +14 -0
  127. package/lib/plugin-management/config-explanation.d.ts +6 -0
  128. package/lib/plugin-management/config-explanation.js +113 -0
  129. package/lib/plugin-management/use-cases.d.ts +15 -0
  130. package/lib/plugin-management/use-cases.js +120 -0
  131. package/lib/plugin-management/workflow.d.ts +136 -0
  132. package/lib/plugin-management/workflow.js +878 -0
  133. package/lib/plugin-management.d.ts +5 -0
  134. package/lib/plugin-management.js +5 -0
  135. package/lib/project/config-file.d.ts +6 -0
  136. package/lib/project/config-file.js +58 -0
  137. package/lib/project/environment.d.ts +13 -0
  138. package/lib/project/environment.js +52 -0
  139. package/lib/project/instance-declarations.d.ts +55 -0
  140. package/lib/project/instance-declarations.js +542 -0
  141. package/lib/project/legacy-config.d.ts +25 -0
  142. package/lib/project/legacy-config.js +70 -0
  143. package/lib/project/node-requirements.d.ts +7 -0
  144. package/lib/project/node-requirements.js +20 -0
  145. package/lib/project/tsx-module-loader.d.ts +7 -0
  146. package/lib/project/tsx-module-loader.js +29 -0
  147. package/lib/project.d.ts +7 -0
  148. package/lib/project.js +7 -0
  149. package/lib/server.d.ts +11 -0
  150. package/lib/server.js +11 -0
  151. package/lib/supervisor/instance-logs.d.ts +39 -0
  152. package/lib/supervisor/instance-logs.js +152 -0
  153. package/lib/supervisor/instance-supervisor.d.ts +140 -0
  154. package/lib/supervisor/instance-supervisor.js +408 -0
  155. package/lib/supervisor/run-instance.d.ts +35 -0
  156. package/lib/supervisor/run-instance.js +161 -0
  157. package/lib/supervisor/terminal-state.d.ts +30 -0
  158. package/lib/supervisor/terminal-state.js +57 -0
  159. package/lib/supervisor.d.ts +4 -0
  160. package/lib/supervisor.js +4 -0
  161. package/lib/terminal/backends.d.ts +38 -0
  162. package/lib/terminal/backends.js +193 -0
  163. package/lib/terminal/config.d.ts +63 -0
  164. package/lib/terminal/config.js +237 -0
  165. package/lib/terminal/host.d.ts +26 -0
  166. package/lib/terminal/host.js +76 -0
  167. package/lib/terminal/index.d.ts +60 -0
  168. package/lib/terminal/index.js +135 -0
  169. package/lib/terminal/modules.d.ts +87 -0
  170. package/lib/terminal/modules.js +44 -0
  171. package/lib/terminal/service.d.ts +101 -0
  172. package/lib/terminal/service.js +455 -0
  173. package/lib/terminal/tickets.d.ts +46 -0
  174. package/lib/terminal/tickets.js +76 -0
  175. package/lib/terminal.d.ts +3 -0
  176. package/lib/terminal.js +3 -0
  177. package/lib/usage/aggregate.d.ts +87 -0
  178. package/lib/usage/aggregate.js +212 -0
  179. package/lib/usage.d.ts +2 -0
  180. package/lib/usage.js +2 -0
  181. package/package.json +117 -4
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The Console's global plugin switch, answered by the gateway (ADR 0054 §4, ADR 0050 §4):
3
+ * `plugin:set-enabled` and `feature.set-enabled` with `level: 'global'` both write
4
+ * `disable_plugins`, which is plugin lifecycle rather than a feature switch. The gateway
5
+ * intercepts them on `POST {base}/console/request` before anything is forwarded, authorizes them
6
+ * with `plugin.manage` (the shared permission map refines `feature.set-enabled` by level), and
7
+ * runs the enable / disable transaction of {@link GatewayPackageService.setEnabled}: stop the
8
+ * instance → write `disable_plugins` → start it, restoring the file and the old instance on
9
+ * failure. `feature.set-enabled` below the global level is forwarded to the instance untouched.
10
+ *
11
+ * Replies keep the shapes the instance used to send, so a published Console keeps working:
12
+ * - `plugin:set-enabled`: `{ success, instanceKey, enabled, disabled, restartRequired, message }`;
13
+ * - `feature.set-enabled`: `ConsoleFeatureWriteResult` (`saved` / `unchanged`) with the
14
+ * configuration revision after the write and the override warnings of the new state.
15
+ * Both add `transaction` (the gateway transaction result). A failed or rolled-back transaction
16
+ * answers the RPC's error envelope (`success: false`, `code`, `error`) with `transaction`.
17
+ */
18
+ import { join } from 'node:path';
19
+ import { auditActor, authorizeConsoleRpc, jsonResponse, rpcAuditDetail, rpcAuditTarget, } from '@zhin.js/host-http';
20
+ import { FEATURE_RPC, PLUGIN_RPC, isGlobalFeatureSwitch, } from '@zhin.js/console-protocol';
21
+ import { DEFAULT_ROOT_CONFIG_FILE_NAME, FEATURE_OVERRIDES_KEY } from '@zhin.js/plugin-runtime';
22
+ import { collectFeatureOverrideWarnings, createConfigDocument } from '@zhin.js/runtime';
23
+ import { formatCompact, getLogger } from '@zhin.js/logger';
24
+ import { GatewayError } from '../packages/errors.js';
25
+ import { findConfigFile } from '../project/config-file.js';
26
+ const logger = getLogger('gateway');
27
+ /** Top-level plugin instance alias (the global switch only applies to top-level instances). */
28
+ const INSTANCE_KEY = /^[a-z0-9][a-z0-9-]*$/u;
29
+ /** Whether the gateway answers this Console RPC message itself (the global plugin switch). */
30
+ export function isGatewayPluginSwitch(message) {
31
+ return message.type === PLUGIN_RPC.SET_ENABLED || isGlobalFeatureSwitch(message);
32
+ }
33
+ export function createGatewayPluginSwitchRpc(options) {
34
+ const readConfig = options.readConfig ?? (async () => {
35
+ const file = join(options.projectRoot, findConfigFile(options.projectRoot) ?? DEFAULT_ROOT_CONFIG_FILE_NAME);
36
+ const { revision, document } = await createConfigDocument(file).read();
37
+ return { revision, document: document };
38
+ });
39
+ return Object.freeze({
40
+ owns: (type) => type === PLUGIN_RPC.SET_ENABLED || type === FEATURE_RPC.SET_ENABLED,
41
+ claims: isGatewayPluginSwitch,
42
+ async handle(message, context, registry) {
43
+ const noStore = { 'cache-control': 'no-store' };
44
+ const type = String(message.type ?? '');
45
+ const requestId = message.requestId;
46
+ const principal = context.principal;
47
+ if (!principal) {
48
+ return jsonResponse(401, { success: false, code: 'unauthorized', error: 'Invalid or missing token', requestId }, noStore);
49
+ }
50
+ // plugin.manage: `plugin:set-enabled` is mapped to it, `feature.set-enabled` is refined by level.
51
+ const decision = authorizeConsoleRpc(principal, message);
52
+ const audit = (outcome) => {
53
+ try {
54
+ registry.accessProvider?.audit?.({
55
+ actor: auditActor(principal),
56
+ action: `rpc:${type}`.slice(0, 256),
57
+ ...(decision.allowed && rpcAuditTarget(decision.rule) ? { target: rpcAuditTarget(decision.rule) } : {}),
58
+ outcome,
59
+ detail: rpcAuditDetail(message),
60
+ ...(context.remoteAddress ? { ip: context.remoteAddress } : {}),
61
+ });
62
+ }
63
+ catch { /* audit never breaks a request */ }
64
+ };
65
+ if (!decision.allowed) {
66
+ audit('denied');
67
+ return jsonResponse(403, { success: false, code: decision.code, error: decision.error, requestId }, noStore);
68
+ }
69
+ const reply = type === PLUGIN_RPC.SET_ENABLED
70
+ ? await setPluginEnabled(message, principal, options, readConfig)
71
+ : await setFeatureGlobal(message, principal, options, readConfig);
72
+ audit(reply.status < 400 && reply.body.success !== false ? 'ok' : reply.status === 403 ? 'denied' : 'error');
73
+ return jsonResponse(reply.status, { ...reply.body, requestId }, noStore);
74
+ },
75
+ });
76
+ }
77
+ /** `plugin:set-enabled` `{ instanceKey, enabled }` (top-level fields, as the Console sends them). */
78
+ async function setPluginEnabled(message, principal, options, readConfig) {
79
+ const type = PLUGIN_RPC.SET_ENABLED;
80
+ const instanceKey = typeof message.instanceKey === 'string' ? message.instanceKey.trim() : '';
81
+ const enabled = message.enabled;
82
+ if (!instanceKey || typeof enabled !== 'boolean')
83
+ return error(400, 'invalid_input', 'instanceKey and boolean enabled are required', type);
84
+ const outcome = await runSwitch(instanceKey, enabled, principal, options, type);
85
+ if ('reply' in outcome)
86
+ return outcome.reply;
87
+ const { result } = outcome;
88
+ const config = await readConfig().catch(() => undefined);
89
+ const key = result.plugin?.instanceKey ?? instanceKey;
90
+ const changed = result.plugin?.changed === true;
91
+ return {
92
+ status: 200,
93
+ body: {
94
+ success: true,
95
+ type,
96
+ data: {
97
+ success: true,
98
+ instanceKey: key,
99
+ enabled,
100
+ disabled: disabledPlugins(config?.document),
101
+ // The gateway restarted the instance for a change (it already is back): the Console re-syncs.
102
+ restartRequired: changed,
103
+ message: switchMessage(key, enabled, changed),
104
+ transaction: result,
105
+ },
106
+ },
107
+ };
108
+ }
109
+ /** `feature.set-enabled` with `level: 'global'`, payload in `data` (or on the message). */
110
+ async function setFeatureGlobal(message, principal, options, readConfig) {
111
+ const type = FEATURE_RPC.SET_ENABLED;
112
+ const data = message.data;
113
+ const input = (data && typeof data === 'object' && !Array.isArray(data) ? data : message);
114
+ const plugin = input.plugin;
115
+ if (typeof plugin !== 'string' || !plugin)
116
+ return error(400, undefined, '缺少 plugin', type);
117
+ if (input.key !== undefined && input.key !== '')
118
+ return error(400, undefined, 'global 层不需要 key', type);
119
+ if (!INSTANCE_KEY.test(plugin)) {
120
+ return error(400, undefined, plugin.includes('/') ? '全局开关只适用于顶层插件实例' : 'plugin 必须是插件实例别名', type);
121
+ }
122
+ if (typeof input.enabled !== 'boolean')
123
+ return error(400, undefined, 'enabled 必须是布尔值', type);
124
+ const enabled = input.enabled;
125
+ const outcome = await runSwitch(plugin, enabled, principal, options, type);
126
+ if ('reply' in outcome)
127
+ return outcome.reply;
128
+ const { result } = outcome;
129
+ const config = await readConfig().catch((failure) => {
130
+ logger.warn(formatCompact({ op: 'gateway_plugin_switch_config_read_failed', error: failure instanceof Error ? failure.message : String(failure) }));
131
+ return undefined;
132
+ });
133
+ const revision = config?.revision ?? '';
134
+ const changed = result.plugin?.changed === true;
135
+ if (!changed) {
136
+ return { status: 200, body: { success: true, type, data: Object.freeze({ status: 'unchanged', revision, warnings: Object.freeze([]), transaction: result }) } };
137
+ }
138
+ return {
139
+ status: 200,
140
+ body: {
141
+ success: true,
142
+ type,
143
+ data: Object.freeze({
144
+ status: 'saved',
145
+ revision,
146
+ // The instance was restarted by the gateway transaction (it already is back).
147
+ restartRequired: true,
148
+ warnings: config ? overrideWarnings(config.document, plugin) : Object.freeze([]),
149
+ message: switchMessage(plugin, enabled, true),
150
+ transaction: result,
151
+ }),
152
+ },
153
+ };
154
+ }
155
+ /** Runs the transaction; a refusal or a failed / rolled-back transaction becomes an error reply. */
156
+ async function runSwitch(plugin, enabled, principal, options, type) {
157
+ if (!options.packages)
158
+ return { reply: error(503, 'unavailable', '插件启停不可用:网关未管理依赖', type) };
159
+ let result;
160
+ try {
161
+ result = await options.packages.setEnabled({ plugin }, enabled, { principal });
162
+ }
163
+ catch (failure) {
164
+ if (failure instanceof GatewayError) {
165
+ return {
166
+ reply: error(failure.status, failure.code, failure.message, type, {
167
+ ...(failure.details?.errors ? { errors: failure.details.errors } : {}),
168
+ ...(failure.details?.missingEnv ? { missingEnv: failure.details.missingEnv } : {}),
169
+ }),
170
+ };
171
+ }
172
+ logger.warn(formatCompact({ op: 'gateway_plugin_switch_failed', plugin, error: failure instanceof Error ? failure.message : String(failure) }));
173
+ return { reply: error(500, 'internal', '插件启停失败', type) };
174
+ }
175
+ if (result.ok)
176
+ return { result };
177
+ const action = enabled ? '启用' : '停用';
178
+ const text = result.status === 'rollbackFailed'
179
+ ? `${action} ${plugin} 失败,且回滚未完成:${result.rollbackError ?? '未知错误'}(原因:${result.error ?? '未知错误'})`
180
+ : `${action} ${plugin} 失败,已回滚:${result.error ?? '未知错误'}`;
181
+ return {
182
+ reply: error(500, result.status === 'rollbackFailed' ? 'rollback_failed' : 'transaction_failed', text, type, {
183
+ transaction: result,
184
+ ...(result.errors?.length ? { errors: result.errors } : {}),
185
+ ...(result.missingEnv?.length ? { missingEnv: result.missingEnv } : {}),
186
+ }),
187
+ };
188
+ }
189
+ function error(status, code, message, type, extra = {}) {
190
+ return { status, body: { success: false, ...(code ? { code } : {}), error: message, type, ...extra } };
191
+ }
192
+ function switchMessage(plugin, enabled, changed) {
193
+ const action = enabled ? '启用' : '停用';
194
+ return changed ? `${plugin} 已${action},实例已由管理网关重启` : `${plugin} 已处于${action}状态`;
195
+ }
196
+ function disabledPlugins(document) {
197
+ const list = document?.disable_plugins;
198
+ return Object.freeze(Array.isArray(list) ? list.filter((item) => typeof item === 'string') : []);
199
+ }
200
+ /** The global plugin switch's warnings about the plugin's own overrides (as the instance computed them). */
201
+ function overrideWarnings(document, plugin) {
202
+ const block = document[plugin];
203
+ const overrides = block && typeof block === 'object' && !Array.isArray(block) ? block[FEATURE_OVERRIDES_KEY] : undefined;
204
+ if (overrides === undefined)
205
+ return Object.freeze([]);
206
+ try {
207
+ const enabled = !disabledPlugins(document).includes(plugin);
208
+ return Object.freeze(collectFeatureOverrideWarnings(document, plugin, overrides, enabled)
209
+ .map(({ code, path, message }) => Object.freeze({ code, path, message })));
210
+ }
211
+ catch {
212
+ return Object.freeze([]);
213
+ }
214
+ }
@@ -0,0 +1,43 @@
1
+ import { type ResolvedHttpConfig } from '@zhin.js/host-http';
2
+ import type { LocalGatewayInfo } from '../local/protocol.js';
3
+ import { type InstanceRestartPolicy } from '../supervisor/instance-supervisor.js';
4
+ import { type RunSupervisedInstanceOptions } from '../supervisor/run-instance.js';
5
+ import { type RunningGateway } from './start-gateway.js';
6
+ /** Set on the instance when its output is echoed to an interactive terminal (TTY-style logging). */
7
+ export declare const INSTANCE_INTERACTIVE_ENV = "ZHIN_INSTANCE_INTERACTIVE";
8
+ export interface RunGatewayOptions extends Omit<RunSupervisedInstanceOptions, 'attach'> {
9
+ /** Overrides the project's `http` block (tests). */
10
+ readonly http?: ResolvedHttpConfig;
11
+ /** Local control socket; `false` disables it. Default: `.zhin/gateway.sock` / the named pipe. */
12
+ readonly localSocket?: false | {
13
+ readonly path?: string;
14
+ readonly uid?: () => number | undefined;
15
+ };
16
+ /** Echo instance output to this process. Default: not in `--daemon` mode. */
17
+ readonly echo?: boolean;
18
+ /** Handle SIGINT / SIGTERM / SIGHUP. Default true. */
19
+ readonly signals?: boolean;
20
+ /** Overrides the crash / storm policy (default: ADR 0054 §5, restarts off with `--once`). */
21
+ readonly restart?: InstanceRestartPolicy;
22
+ /** Called once the gateway serves HTTP and the local socket. */
23
+ readonly onReady?: (gateway: RunningGatewayProcess) => void;
24
+ }
25
+ export interface RunningGatewayProcess {
26
+ readonly gateway: RunningGateway;
27
+ readonly info: LocalGatewayInfo;
28
+ /** Ends the run (as `gateway.shutdown` does). */
29
+ shutdown(exitCode?: number): void;
30
+ }
31
+ /** `@zhin.js/gateway` version. */
32
+ export declare function gatewayVersion(): string;
33
+ /** Console link in the ADR 0053 format: `?apiBaseUrl=`, the token only after `#`. */
34
+ export declare function consoleLink(origin: string, token?: string): string;
35
+ /**
36
+ * Whether the instance gets the gateway's stdin: a foreground gateway whose stdin is a TTY and
37
+ * that is the terminal's foreground job (a background job's child reading the TTY gets SIGTTIN).
38
+ */
39
+ export declare function foregroundStdin(daemon: boolean, stdin?: {
40
+ readonly isTTY?: boolean;
41
+ }, foregroundGroup?: () => boolean): boolean;
42
+ /** Runs the gateway until it is shut down; resolves with the exit code for this process. */
43
+ export declare function runGateway(input: RunGatewayOptions): Promise<number>;
@@ -0,0 +1,268 @@
1
+ /**
2
+ * The gateway process (ADR 0054 §1 / §5 / §6): `zhin start` / `zhin runtime start` / `pnpm dev`.
3
+ * Long-lived: the instance crashing, entering `crashed` after a restart storm, or being stopped
4
+ * from the Console / CLI does not end it. It ends on SIGINT / SIGTERM (both processes stop) or
5
+ * `gateway.shutdown` on the local control socket. Only `--once` (CI) still ends with the instance.
6
+ */
7
+ import { randomBytes } from 'node:crypto';
8
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { mkdir, rm } from 'node:fs/promises';
10
+ import { dirname, join } from 'node:path';
11
+ import { REMOTE_CONSOLE_ORIGIN } from '@zhin.js/host-http';
12
+ import { GATEWAY_SOCKET_ENV } from '@zhin.js/host-http/bridge';
13
+ import { formatCompact, getLogger } from '@zhin.js/logger';
14
+ import { instanceStatus } from '../packages/rpc.js';
15
+ import { createLocalRpcHandler } from '../local/handler.js';
16
+ import { LocalControlServer, LocalSocketError, probeLocalSocket } from '../local/local-socket.js';
17
+ import { gatewayInfoFile, gatewaySocketPath, isNamedPipe } from '../local/paths.js';
18
+ import { readGatewayInfo, removeGatewayInfo, writeGatewayInfo } from '../local/state-file.js';
19
+ import { isNodeVersionSupported, NODE_ENGINES_HINT } from '../project/node-requirements.js';
20
+ import { InstanceSupervisor } from '../supervisor/instance-supervisor.js';
21
+ import { defaultInstanceLogFile, supervisedNodeArguments } from '../supervisor/run-instance.js';
22
+ import { captureTerminalState, isForegroundProcessGroup } from '../supervisor/terminal-state.js';
23
+ import { startGateway } from './start-gateway.js';
24
+ /** Set on the instance when its output is echoed to an interactive terminal (TTY-style logging). */
25
+ export const INSTANCE_INTERACTIVE_ENV = 'ZHIN_INSTANCE_INTERACTIVE';
26
+ let cachedVersion;
27
+ /** `@zhin.js/gateway` version. */
28
+ export function gatewayVersion() {
29
+ if (cachedVersion)
30
+ return cachedVersion;
31
+ try {
32
+ const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
33
+ cachedVersion = typeof manifest.version === 'string' ? manifest.version : '0.0.0';
34
+ }
35
+ catch {
36
+ cachedVersion = '0.0.0';
37
+ }
38
+ return cachedVersion;
39
+ }
40
+ /** Console link in the ADR 0053 format: `?apiBaseUrl=`, the token only after `#`. */
41
+ export function consoleLink(origin, token) {
42
+ const url = `${REMOTE_CONSOLE_ORIGIN}/?apiBaseUrl=${encodeURIComponent(origin)}`;
43
+ return token ? `${url}#token=${encodeURIComponent(token)}` : url;
44
+ }
45
+ /**
46
+ * Whether the instance gets the gateway's stdin: a foreground gateway whose stdin is a TTY and
47
+ * that is the terminal's foreground job (a background job's child reading the TTY gets SIGTTIN).
48
+ */
49
+ export function foregroundStdin(daemon, stdin = process.stdin, foregroundGroup = isForegroundProcessGroup) {
50
+ return !daemon && stdin.isTTY === true && foregroundGroup();
51
+ }
52
+ /**
53
+ * The local control endpoint of this run. Windows named pipes get a fresh random name component
54
+ * (Node cannot set a DACL on them; see `windowsPipeName`), so a second gateway on the project is
55
+ * detected through the pipe the state file records instead of a name clash.
56
+ */
57
+ async function localEndpoint(root, platform = process.platform) {
58
+ if (platform !== 'win32')
59
+ return gatewaySocketPath(root, { platform });
60
+ const recorded = await readGatewayInfo(root);
61
+ if (recorded && isNamedPipe(recorded.socket) && await probeLocalSocket(recorded.socket).catch(() => 'live') === 'live') {
62
+ throw new LocalSocketError('in_use', `另一个网关正在使用 ${recorded.socket}`);
63
+ }
64
+ return gatewaySocketPath(root, { platform, nonce: randomBytes(8).toString('hex') });
65
+ }
66
+ /** Strips the `zhin runtime start` / `zhin start` prefix of the instance arguments. */
67
+ function startArguments(args) {
68
+ if (args[0] === 'runtime' && args[1] === 'start')
69
+ return args.slice(2);
70
+ if (args[0] === 'start')
71
+ return args.slice(1);
72
+ return args;
73
+ }
74
+ /** Runs the gateway until it is shut down; resolves with the exit code for this process. */
75
+ export async function runGateway(input) {
76
+ if (!isNodeVersionSupported()) {
77
+ throw new Error(`zhin start requires Node ${NODE_ENGINES_HINT}; found ${process.versions.node}`);
78
+ }
79
+ const entry = input.entry ?? process.argv[1];
80
+ if (!entry)
81
+ throw new Error('Cannot determine the zhin executable path');
82
+ const { root, options } = input;
83
+ const logger = getLogger('gateway');
84
+ const args = [...(input.args ?? process.argv.slice(2))];
85
+ const daemon = options.daemon;
86
+ const echo = input.echo ?? !daemon;
87
+ const interactive = echo && process.stdout.isTTY === true;
88
+ const logFile = options.logFile ?? defaultInstanceLogFile(root);
89
+ await mkdir(dirname(logFile), { recursive: true });
90
+ const socketPath = input.localSocket === false ? undefined : input.localSocket?.path ?? await localEndpoint(root);
91
+ // Keyed proof the CLI checks before it talks to the endpoint (only in the 0600 state file).
92
+ const handshakeKey = randomBytes(32).toString('hex');
93
+ const shareTerminal = foregroundStdin(daemon);
94
+ // The instance may leave the shared terminal raw / without echo when it exits or crashes.
95
+ const terminal = shareTerminal ? captureTerminalState() : undefined;
96
+ // gateway.readyTimeout: startGateway re-reads it before every start, restart and transaction.
97
+ const supervisor = new InstanceSupervisor({
98
+ spawn: {
99
+ command: process.execPath,
100
+ args: [...supervisedNodeArguments(), entry, ...args],
101
+ cwd: root,
102
+ env: {
103
+ ...process.env,
104
+ // The instance keeps the Agent's file / shell tools away from the owner endpoint.
105
+ ...(socketPath ? { [GATEWAY_SOCKET_ENV]: socketPath } : {}),
106
+ ...(interactive ? { [INSTANCE_INTERACTIVE_ENV]: '1', FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}),
107
+ },
108
+ },
109
+ output: 'pipe',
110
+ // Foreground on a TTY: the instance reads the terminal (terminal adapters); never in the background.
111
+ input: shareTerminal ? 'inherit' : 'ignore',
112
+ logs: { ...input.logs, file: logFile },
113
+ // The gateway outlives the instance (ADR 0054 §5); `--once` is a single check run.
114
+ restart: { onCrash: !options.once, onRequest: !options.once, ...input.restart },
115
+ });
116
+ if (echo) {
117
+ supervisor.on('log', (line) => {
118
+ (line.stream === 'stderr' ? process.stderr : process.stdout).write(`${line.line}\n`);
119
+ });
120
+ }
121
+ supervisor.on('state', (status) => {
122
+ if (terminal && (status.state === 'stopped' || status.state === 'crashed' || status.state === 'backoff'))
123
+ terminal.restore();
124
+ logger.info(formatCompact({ op: 'instance_state', state: status.state, ...(status.pid ? { pid: status.pid } : {}) }));
125
+ });
126
+ supervisor.on('failure', (failure) => {
127
+ logger.warn(formatCompact({ op: 'instance_failure', reason: failure.reason, message: failure.message }));
128
+ });
129
+ supervisor.on('restart-request', (request) => {
130
+ logger.info(formatCompact({ op: 'instance_restart_request', reason: request.reason }));
131
+ });
132
+ const control = socketPath
133
+ ? await LocalControlServer.open({
134
+ path: socketPath,
135
+ handshakeKey,
136
+ // No other gateway serves the project: a state file left by a dead one is stale.
137
+ beforeListen: () => rm(gatewayInfoFile(root), { force: true }),
138
+ ...(input.localSocket && input.localSocket.uid ? { uid: input.localSocket.uid } : {}),
139
+ }).catch((error) => { supervisor.dispose(); throw error; })
140
+ : undefined;
141
+ let gateway;
142
+ try {
143
+ gateway = await startGateway({
144
+ projectRoot: root,
145
+ environment: options.environment,
146
+ ...(input.http ? { http: input.http } : {}),
147
+ supervisor,
148
+ transport: () => supervisor.transport,
149
+ });
150
+ }
151
+ catch (error) {
152
+ await control?.close();
153
+ supervisor.dispose();
154
+ throw error;
155
+ }
156
+ let resolveEnd;
157
+ const ended = new Promise((resolve) => { resolveEnd = resolve; });
158
+ let ending = false;
159
+ const shutdown = (exitCode = 0) => {
160
+ if (ending)
161
+ return;
162
+ ending = true;
163
+ logger.info(formatCompact({ op: 'gateway_stop' }));
164
+ resolveEnd(exitCode);
165
+ };
166
+ const addresses = gateway.http.addresses;
167
+ const info = Object.freeze({
168
+ pid: process.pid,
169
+ ports: Object.freeze(addresses.map((address) => address.port)),
170
+ origins: Object.freeze(addresses.map((address) => address.origin)),
171
+ socket: socketPath ?? '',
172
+ startedAt: Date.now(),
173
+ version: gatewayVersion(),
174
+ mode: daemon ? 'daemon' : 'foreground',
175
+ args: Object.freeze([...startArguments(args)]),
176
+ logFile,
177
+ });
178
+ const infoResult = () => Object.freeze({
179
+ gateway: info,
180
+ instance: instanceStatus(supervisor.status()),
181
+ consoleInitialized: gateway.http.tokenRegistry.initialized(),
182
+ });
183
+ control?.setHandler(createLocalRpcHandler({
184
+ dispatch: gateway.rpc,
185
+ ...(gateway.packages ? { packages: gateway.packages } : {}),
186
+ ...(gateway.console ? { console: gateway.console.localAdmin } : {}),
187
+ info: infoResult,
188
+ shutdown: () => shutdown(0),
189
+ http: (request) => gateway.http.localRequest(request),
190
+ }));
191
+ const pidFile = join(root, '.zhin.pid');
192
+ try {
193
+ writeFileSync(pidFile, String(process.pid));
194
+ }
195
+ catch { /* informational */ }
196
+ if (socketPath) {
197
+ await writeGatewayInfo(root, { ...info, handshakeKey }).catch((error) => {
198
+ logger.warn(formatCompact({ op: 'gateway_info_write_failed', error: error instanceof Error ? error.message : String(error) }));
199
+ });
200
+ }
201
+ const origin = info.origins[0];
202
+ logger.info(formatCompact({
203
+ op: 'gateway_listening', pid: process.pid, ...(origin ? { http: origin } : {}),
204
+ ...(socketPath ? { socket: socketPath } : {}), log: logFile,
205
+ }));
206
+ if (origin && !options.once)
207
+ logger.info(`Console: ${consoleLink(origin)}`);
208
+ let interrupted = 0;
209
+ const onSignal = (signal) => {
210
+ interrupted += 1;
211
+ // A second signal kills the instance without waiting for its graceful stop.
212
+ if (interrupted > 1)
213
+ void supervisor.stop({ reason: 'signal', force: true });
214
+ shutdown(signal === 'SIGINT' ? 130 : 0);
215
+ };
216
+ const onSighup = () => { if (!daemon)
217
+ onSignal('SIGHUP'); };
218
+ const onExit = () => {
219
+ supervisor.dispose();
220
+ terminal?.restore();
221
+ removePidFile();
222
+ };
223
+ const removePidFile = () => {
224
+ try {
225
+ if (readFileSync(pidFile, 'utf8').trim() === String(process.pid))
226
+ rmSync(pidFile, { force: true });
227
+ }
228
+ catch { /* already gone */ }
229
+ };
230
+ const signals = input.signals !== false;
231
+ if (signals) {
232
+ process.on('SIGINT', onSignal);
233
+ process.on('SIGTERM', onSignal);
234
+ process.on('SIGHUP', onSighup);
235
+ }
236
+ process.on('exit', onExit);
237
+ // `--once`: the run ends with the instance's single generation (CI check).
238
+ if (options.once) {
239
+ const check = (status = supervisor.status()) => {
240
+ if ((status.state === 'stopped' || status.state === 'crashed') && !gateway.ownsInstanceLifecycle()) {
241
+ shutdown(status.state === 'crashed' ? status.lastExitCode || 1 : status.lastExitCode ?? 0);
242
+ }
243
+ };
244
+ void gateway.instanceStarted.then(() => {
245
+ supervisor.on('state', check);
246
+ check();
247
+ });
248
+ }
249
+ input.onReady?.({ gateway, info, shutdown });
250
+ try {
251
+ return await ended;
252
+ }
253
+ finally {
254
+ await gateway.close().catch((error) => {
255
+ logger.warn(formatCompact({ op: 'gateway_close_failed', error: error instanceof Error ? error.message : String(error) }));
256
+ });
257
+ await control?.close().catch(() => undefined);
258
+ if (socketPath)
259
+ await removeGatewayInfo(root).catch(() => undefined);
260
+ if (signals) {
261
+ process.off('SIGINT', onSignal);
262
+ process.off('SIGTERM', onSignal);
263
+ process.off('SIGHUP', onSighup);
264
+ }
265
+ process.off('exit', onExit);
266
+ removePidFile();
267
+ }
268
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Gateway composition (ADR 0054 §1): the HTTP server and the instance supervisor, joined by a
3
+ * pluggable instance transport (the bridge supplies the IPC one). CLI wiring lives elsewhere.
4
+ */
5
+ import { type ResolvedHttpConfig, type TokenRegistry } from '@zhin.js/host-http';
6
+ import type { InstanceTransport } from '@zhin.js/host-http/bridge';
7
+ import { InstanceSupervisor, type InstanceSupervisorOptions } from '../supervisor/instance-supervisor.js';
8
+ import { GatewayPackageService } from '../packages/service.js';
9
+ import { type GatewayRpcDispatcher } from '../packages/rpc.js';
10
+ import { type GatewayConsole, type GatewayConsoleInstancePort, type GatewayConsoleOptions } from './gateway-console.js';
11
+ import { GatewayHttpServer } from './gateway-http-server.js';
12
+ /**
13
+ * A transport the gateway owns; `dispose` runs on gateway close. The Console module also uses the
14
+ * optional {@link GatewayConsoleInstancePort} methods (plugin catalog, instance audit,
15
+ * `principal-revoked`) when the transport has them (`IpcInstanceTransport` does).
16
+ */
17
+ export interface GatewayInstanceTransport extends InstanceTransport, GatewayConsoleInstancePort {
18
+ dispose?(): void | Promise<void>;
19
+ /** Told the bound listener addresses after listen (the instance's `HttpHost.address`). */
20
+ setAddresses?(addresses: readonly import('@zhin.js/host-http').HttpHostAddress[]): void;
21
+ }
22
+ export interface InstanceTransportFactoryContext {
23
+ readonly supervisor: InstanceSupervisor;
24
+ readonly projectRoot: string;
25
+ }
26
+ export type InstanceTransportFactory = (context: InstanceTransportFactoryContext) => GatewayInstanceTransport;
27
+ export interface StartGatewayOptions {
28
+ readonly projectRoot: string;
29
+ /** Selects `.env.<environment>` for `http.*` placeholders. Default `development`. */
30
+ readonly environment?: string;
31
+ /** Overrides the `http` block of the project config. */
32
+ readonly http?: ResolvedHttpConfig;
33
+ /** Instance supervision; pass a supervisor to own its construction. */
34
+ readonly supervisor: InstanceSupervisorOptions | InstanceSupervisor;
35
+ readonly transport: InstanceTransportFactory;
36
+ /** Console authentication registry (the Console module installs its access provider on it). */
37
+ readonly tokenRegistry?: TokenRegistry;
38
+ /**
39
+ * Console access control in the gateway (project database, `/auth/*`, `iam.*`, setup,
40
+ * onboarding, terminal, audit). Default on; `false` leaves only the bootstrap token (tests).
41
+ */
42
+ readonly console?: false | Omit<GatewayConsoleOptions, 'server' | 'projectRoot' | 'environment' | 'instance' | 'restartInstance'>;
43
+ /** Longest wait for the project database before listening anyway (default 10 s). */
44
+ readonly databaseWaitMs?: number;
45
+ readonly retryAfterSeconds?: number;
46
+ readonly maxRequestBodyBytes?: number;
47
+ /** Start the instance after the server listens. Default true; a failed start keeps the gateway up. */
48
+ readonly startInstance?: boolean;
49
+ /**
50
+ * Dependency transactions (`gateway.packages.*`). Default: a {@link GatewayPackageService} on
51
+ * the project root over the supervisor; `false` disables the package RPC.
52
+ */
53
+ readonly packages?: GatewayPackageService | false;
54
+ /**
55
+ * `gateway.readyTimeout` for dependency transactions when the project sets none (see
56
+ * {@link loadGatewaySettings}). Default 60 s.
57
+ */
58
+ readonly readyTimeoutMs?: number;
59
+ /**
60
+ * Reads the project's `gateway` block. Called before every instance start / restart (Console,
61
+ * CLI, automatic restart after a crash) and every dependency transaction, so a changed
62
+ * `gateway.readyTimeout` applies without restarting the gateway. Default
63
+ * {@link loadGatewaySettings} on the project root; `false` reads nothing.
64
+ */
65
+ readonly settings?: false | (() => Promise<GatewaySettings>);
66
+ }
67
+ export interface RunningGateway {
68
+ readonly http: GatewayHttpServer;
69
+ readonly supervisor: InstanceSupervisor;
70
+ readonly transport: GatewayInstanceTransport;
71
+ readonly packages: GatewayPackageService | undefined;
72
+ /** Console access control; undefined with `console: false`. */
73
+ readonly console: GatewayConsole | undefined;
74
+ /** The gateway RPC (`gateway.packages.*`, `gateway.instance.*`), shared with the local socket. */
75
+ readonly rpc: GatewayRpcDispatcher;
76
+ /**
77
+ * True while the gateway owns the instance's lifecycle: a dependency transaction runs, or the
78
+ * Console stopped the instance (a run must not end because of it).
79
+ */
80
+ ownsInstanceLifecycle(): boolean;
81
+ /** Resolves when the first instance start attempt settled (never rejects). */
82
+ readonly instanceStarted: Promise<void>;
83
+ /** Stops the instance, then closes the listeners. */
84
+ close(): Promise<void>;
85
+ }
86
+ /**
87
+ * Reads and resolves the project's `http` config the same way the instance used to. Only the
88
+ * `http` block is read: plugin declarations are not validated here, so a config the instance
89
+ * rejects still leaves the Console reachable.
90
+ */
91
+ export declare function loadGatewayHttpConfig(projectRoot: string, environment?: string): Promise<ResolvedHttpConfig>;
92
+ /** The project's `gateway` block (ADR 0054 §4 / §5). */
93
+ export interface GatewaySettings {
94
+ /** `gateway.readyTimeout` (seconds) in ms; undefined: the built-in 60 s. */
95
+ readonly readyTimeoutMs?: number;
96
+ }
97
+ /**
98
+ * Reads the `gateway` block. An invalid value is logged and ignored (the instance rejects the
99
+ * config on its own); like {@link loadGatewayHttpConfig}, it never keeps the gateway down.
100
+ */
101
+ export declare function loadGatewaySettings(projectRoot: string): Promise<GatewaySettings>;
102
+ export declare function startGateway(options: StartGatewayOptions): Promise<RunningGateway>;
103
+ /** Thrown when the gateway would serve an open Host on a network address. */
104
+ export declare class OpenHostOnNetworkError extends Error {
105
+ readonly host: string;
106
+ readonly code = "ZHIN_OPEN_HOST_ON_NETWORK";
107
+ constructor(host: string);
108
+ }
109
+ /**
110
+ * An open Host (no bootstrap token, no owner yet) holds every permission and is only meant for
111
+ * this machine. The per-request loopback check cannot see through a same-host reverse proxy that
112
+ * adds no forwarding header, so a network-facing `http.host` must not start open (ADR 0049 §1).
113
+ */
114
+ export declare function assertNotOpenOnNetwork(http: ResolvedHttpConfig, registry: TokenRegistry): void;