@zhin.js/gateway 0.0.0-stage → 1.1.1

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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -2
  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 +24 -0
  14. package/lib/config/configuration-redaction.js +128 -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 +222 -0
  19. package/lib/config/environment-files.d.ts +2 -0
  20. package/lib/config/environment-files.js +21 -0
  21. package/lib/config/plugin-package-map.d.ts +2 -0
  22. package/lib/config/plugin-package-map.js +14 -0
  23. package/lib/config/plugin-schema-catalog.d.ts +22 -0
  24. package/lib/config/plugin-schema-catalog.js +262 -0
  25. package/lib/config.d.ts +12 -0
  26. package/lib/config.js +12 -0
  27. package/lib/database/config.d.ts +22 -0
  28. package/lib/database/config.js +159 -0
  29. package/lib/database/index.d.ts +2 -0
  30. package/lib/database/index.js +2 -0
  31. package/lib/database/project-database.d.ts +31 -0
  32. package/lib/database/project-database.js +130 -0
  33. package/lib/database.d.ts +2 -0
  34. package/lib/database.js +2 -0
  35. package/lib/http/console-rpc.d.ts +19 -0
  36. package/lib/http/console-rpc.js +51 -0
  37. package/lib/http/gateway-console.d.ts +63 -0
  38. package/lib/http/gateway-console.js +309 -0
  39. package/lib/http/gateway-events.d.ts +35 -0
  40. package/lib/http/gateway-events.js +172 -0
  41. package/lib/http/gateway-http-server.d.ts +95 -0
  42. package/lib/http/gateway-http-server.js +518 -0
  43. package/lib/http/local-principal.d.ts +2 -0
  44. package/lib/http/local-principal.js +16 -0
  45. package/lib/http/run-gateway.d.ts +43 -0
  46. package/lib/http/run-gateway.js +268 -0
  47. package/lib/http/start-gateway.d.ts +114 -0
  48. package/lib/http/start-gateway.js +220 -0
  49. package/lib/http-response.d.ts +6 -0
  50. package/lib/http-response.js +25 -0
  51. package/lib/http.d.ts +6 -0
  52. package/lib/http.js +6 -0
  53. package/lib/iam/crypto.d.ts +12 -0
  54. package/lib/iam/crypto.js +58 -0
  55. package/lib/iam/index.d.ts +8 -0
  56. package/lib/iam/index.js +8 -0
  57. package/lib/iam/install.d.ts +36 -0
  58. package/lib/iam/install.js +74 -0
  59. package/lib/iam/local-admin.d.ts +27 -0
  60. package/lib/iam/local-admin.js +102 -0
  61. package/lib/iam/rate-limit.d.ts +15 -0
  62. package/lib/iam/rate-limit.js +52 -0
  63. package/lib/iam/routes.d.ts +23 -0
  64. package/lib/iam/routes.js +157 -0
  65. package/lib/iam/rpc.d.ts +14 -0
  66. package/lib/iam/rpc.js +100 -0
  67. package/lib/iam/service.d.ts +203 -0
  68. package/lib/iam/service.js +784 -0
  69. package/lib/iam/setup-service.d.ts +83 -0
  70. package/lib/iam/setup-service.js +206 -0
  71. package/lib/iam/tables.d.ts +20 -0
  72. package/lib/iam/tables.js +82 -0
  73. package/lib/iam.d.ts +2 -0
  74. package/lib/iam.js +2 -0
  75. package/lib/index.d.ts +22 -0
  76. package/lib/index.js +22 -0
  77. package/lib/local/client.d.ts +71 -0
  78. package/lib/local/client.js +296 -0
  79. package/lib/local/handler.d.ts +30 -0
  80. package/lib/local/handler.js +86 -0
  81. package/lib/local/local-socket.d.ts +55 -0
  82. package/lib/local/local-socket.js +232 -0
  83. package/lib/local/paths.d.ts +34 -0
  84. package/lib/local/paths.js +58 -0
  85. package/lib/local/protocol.d.ts +140 -0
  86. package/lib/local/protocol.js +90 -0
  87. package/lib/local/state-file.d.ts +8 -0
  88. package/lib/local/state-file.js +45 -0
  89. package/lib/local.d.ts +10 -0
  90. package/lib/local.js +10 -0
  91. package/lib/onboarding/allowlist.d.ts +54 -0
  92. package/lib/onboarding/allowlist.js +106 -0
  93. package/lib/onboarding/errors.d.ts +7 -0
  94. package/lib/onboarding/errors.js +17 -0
  95. package/lib/onboarding/index.d.ts +6 -0
  96. package/lib/onboarding/index.js +6 -0
  97. package/lib/onboarding/install.d.ts +99 -0
  98. package/lib/onboarding/install.js +132 -0
  99. package/lib/onboarding/package-manager.d.ts +47 -0
  100. package/lib/onboarding/package-manager.js +179 -0
  101. package/lib/onboarding/service.d.ts +53 -0
  102. package/lib/onboarding/service.js +144 -0
  103. package/lib/onboarding/state-store.d.ts +26 -0
  104. package/lib/onboarding/state-store.js +97 -0
  105. package/lib/onboarding.d.ts +2 -0
  106. package/lib/onboarding.js +2 -0
  107. package/lib/packages/authorization.d.ts +23 -0
  108. package/lib/packages/authorization.js +109 -0
  109. package/lib/packages/errors.d.ts +11 -0
  110. package/lib/packages/errors.js +26 -0
  111. package/lib/packages/index.d.ts +5 -0
  112. package/lib/packages/index.js +5 -0
  113. package/lib/packages/rpc.d.ts +30 -0
  114. package/lib/packages/rpc.js +155 -0
  115. package/lib/packages/service.d.ts +121 -0
  116. package/lib/packages/service.js +441 -0
  117. package/lib/packages/snapshot.d.ts +14 -0
  118. package/lib/packages/snapshot.js +73 -0
  119. package/lib/packages.d.ts +2 -0
  120. package/lib/packages.js +2 -0
  121. package/lib/plugin-management/activity.d.ts +6 -0
  122. package/lib/plugin-management/activity.js +14 -0
  123. package/lib/plugin-management/config-explanation.d.ts +6 -0
  124. package/lib/plugin-management/config-explanation.js +113 -0
  125. package/lib/plugin-management/use-cases.d.ts +15 -0
  126. package/lib/plugin-management/use-cases.js +120 -0
  127. package/lib/plugin-management/workflow.d.ts +92 -0
  128. package/lib/plugin-management/workflow.js +772 -0
  129. package/lib/plugin-management.d.ts +5 -0
  130. package/lib/plugin-management.js +5 -0
  131. package/lib/project/config-file.d.ts +6 -0
  132. package/lib/project/config-file.js +58 -0
  133. package/lib/project/environment.d.ts +13 -0
  134. package/lib/project/environment.js +52 -0
  135. package/lib/project/instance-declarations.d.ts +55 -0
  136. package/lib/project/instance-declarations.js +542 -0
  137. package/lib/project/legacy-config.d.ts +25 -0
  138. package/lib/project/legacy-config.js +70 -0
  139. package/lib/project/node-requirements.d.ts +7 -0
  140. package/lib/project/node-requirements.js +20 -0
  141. package/lib/project/tsx-module-loader.d.ts +7 -0
  142. package/lib/project/tsx-module-loader.js +29 -0
  143. package/lib/project.d.ts +7 -0
  144. package/lib/project.js +7 -0
  145. package/lib/server.d.ts +11 -0
  146. package/lib/server.js +11 -0
  147. package/lib/supervisor/instance-logs.d.ts +39 -0
  148. package/lib/supervisor/instance-logs.js +152 -0
  149. package/lib/supervisor/instance-supervisor.d.ts +140 -0
  150. package/lib/supervisor/instance-supervisor.js +408 -0
  151. package/lib/supervisor/run-instance.d.ts +35 -0
  152. package/lib/supervisor/run-instance.js +161 -0
  153. package/lib/supervisor/terminal-state.d.ts +30 -0
  154. package/lib/supervisor/terminal-state.js +57 -0
  155. package/lib/supervisor.d.ts +4 -0
  156. package/lib/supervisor.js +4 -0
  157. package/lib/terminal/backends.d.ts +38 -0
  158. package/lib/terminal/backends.js +193 -0
  159. package/lib/terminal/config.d.ts +63 -0
  160. package/lib/terminal/config.js +237 -0
  161. package/lib/terminal/host.d.ts +26 -0
  162. package/lib/terminal/host.js +76 -0
  163. package/lib/terminal/index.d.ts +60 -0
  164. package/lib/terminal/index.js +135 -0
  165. package/lib/terminal/modules.d.ts +87 -0
  166. package/lib/terminal/modules.js +44 -0
  167. package/lib/terminal/service.d.ts +101 -0
  168. package/lib/terminal/service.js +455 -0
  169. package/lib/terminal/tickets.d.ts +46 -0
  170. package/lib/terminal/tickets.js +76 -0
  171. package/lib/terminal.d.ts +3 -0
  172. package/lib/terminal.js +3 -0
  173. package/lib/usage/aggregate.d.ts +87 -0
  174. package/lib/usage/aggregate.js +212 -0
  175. package/lib/usage.d.ts +2 -0
  176. package/lib/usage.js +2 -0
  177. package/package.json +116 -3
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The foreground gateway's terminal, shared with the instance when it inherits stdin (terminal
3
+ * adapters, login assist). Node has no `tcgetpgrp` / termios API, so this uses `/proc` and
4
+ * `stty` / `ps` (Unix only; Windows consoles are left as they are).
5
+ */
6
+ import { spawnSync } from 'node:child_process';
7
+ import { readFileSync } from 'node:fs';
8
+ /**
9
+ * Whether this process is in the terminal's foreground process group. A background job
10
+ * (`zhin start --foreground &`) must not hand its TTY stdin to the instance: reading it would
11
+ * stop the instance with SIGTTIN. Unknown (no controlling terminal, probe failed) counts as
12
+ * foreground, the previous behaviour.
13
+ */
14
+ export function isForegroundProcessGroup(probe = {}) {
15
+ const platform = probe.platform ?? process.platform;
16
+ if (platform === 'win32')
17
+ return true;
18
+ const pid = probe.pid ?? process.pid;
19
+ try {
20
+ if (platform === 'linux') {
21
+ const stat = (probe.readStat ?? ((id) => readFileSync(`/proc/${id}/stat`, 'utf8')))(pid);
22
+ // `pid (comm) state ppid pgrp session tty_nr tpgid …`; comm may contain spaces and ')'.
23
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/u);
24
+ const pgrp = Number(fields[2]);
25
+ const tpgid = Number(fields[5]);
26
+ if (!Number.isInteger(pgrp) || !Number.isInteger(tpgid) || tpgid <= 0)
27
+ return true;
28
+ return pgrp === tpgid;
29
+ }
30
+ const output = (probe.ps ?? ((id) => spawnSync('ps', ['-o', 'pgid=,tpgid=', '-p', String(id)], { encoding: 'utf8' }).stdout ?? ''))(pid);
31
+ const [pgid, tpgid] = output.trim().split(/\s+/u).map(Number);
32
+ if (!Number.isInteger(pgid) || !Number.isInteger(tpgid) || tpgid <= 0)
33
+ return true;
34
+ return pgid === tpgid;
35
+ }
36
+ catch {
37
+ return true;
38
+ }
39
+ }
40
+ const runStty = (args) => {
41
+ const result = spawnSync('stty', [...args], { stdio: ['inherit', 'pipe', 'ignore'], encoding: 'utf8' });
42
+ return result.status === 0 ? result.stdout.trim() : undefined;
43
+ };
44
+ /**
45
+ * Saves the terminal settings (`stty -g`) so they can be restored once the instance that shares
46
+ * the TTY exits — a crashed terminal adapter can leave it in raw / no-echo mode. Undefined on
47
+ * Windows or without a usable `stty`.
48
+ */
49
+ export function captureTerminalState(options = {}) {
50
+ if ((options.platform ?? process.platform) === 'win32')
51
+ return undefined;
52
+ const stty = options.stty ?? runStty;
53
+ const saved = stty(['-g']);
54
+ if (!saved)
55
+ return undefined;
56
+ return Object.freeze({ restore: () => { stty([saved]); } });
57
+ }
@@ -0,0 +1,4 @@
1
+ /** `@zhin.js/gateway/supervisor` — instance process supervision over the IPC control channel (ADR 0054 §3 / §5). */
2
+ export * from './supervisor/instance-supervisor.js';
3
+ export * from './supervisor/instance-logs.js';
4
+ export * from './supervisor/run-instance.js';
@@ -0,0 +1,4 @@
1
+ /** `@zhin.js/gateway/supervisor` — instance process supervision over the IPC control channel (ADR 0054 §3 / §5). */
2
+ export * from './supervisor/instance-supervisor.js';
3
+ export * from './supervisor/instance-logs.js';
4
+ export * from './supervisor/run-instance.js';
@@ -0,0 +1,38 @@
1
+ import { type SshCredentials, type TerminalSshTargetConfig } from './config.js';
2
+ import type { PtyModule, SshModule } from './modules.js';
3
+ export interface TerminalSize {
4
+ readonly cols: number;
5
+ readonly rows: number;
6
+ }
7
+ export interface TerminalExit {
8
+ readonly code: number | null;
9
+ readonly signal?: string | number;
10
+ }
11
+ export interface TerminalBackendEvents {
12
+ onData(chunk: Buffer | string): void;
13
+ onExit(exit: TerminalExit): void;
14
+ }
15
+ /** A running shell (local PTY or SSH channel). `kill()` is idempotent. */
16
+ export interface TerminalBackend {
17
+ write(data: Buffer): void;
18
+ resize(size: TerminalSize): void;
19
+ kill(): void;
20
+ pause(): void;
21
+ resume(): void;
22
+ }
23
+ export declare class TerminalStartError extends Error {
24
+ readonly code: string;
25
+ constructor(code: string, message: string);
26
+ }
27
+ export declare function startLocalBackend(pty: PtyModule, options: {
28
+ shell: string;
29
+ cwd: string;
30
+ env?: NodeJS.ProcessEnv;
31
+ }, size: TerminalSize, events: TerminalBackendEvents): TerminalBackend;
32
+ export interface SshStartOptions {
33
+ readonly readyTimeoutMs?: number;
34
+ /** Receives the observed host fingerprint (not secret) so operators can pin it. */
35
+ readonly onHostKey?: (fingerprint: string) => void;
36
+ }
37
+ /** Connects and opens an interactive shell. Resolves once the channel is open. */
38
+ export declare function startSshBackend(ssh: SshModule, target: TerminalSshTargetConfig, credentials: SshCredentials, size: TerminalSize, events: TerminalBackendEvents, options?: SshStartOptions): Promise<TerminalBackend>;
@@ -0,0 +1,193 @@
1
+ import { hostKeyFingerprint } from './config.js';
2
+ export class TerminalStartError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = 'TerminalStartError';
8
+ }
9
+ }
10
+ const TERM = 'xterm-256color';
11
+ export function startLocalBackend(pty, options, size, events) {
12
+ const env = {};
13
+ for (const [key, value] of Object.entries(options.env ?? process.env)) {
14
+ if (typeof value === 'string')
15
+ env[key] = value;
16
+ }
17
+ env.TERM = TERM;
18
+ let proc;
19
+ try {
20
+ proc = pty.spawn(options.shell, [], { name: TERM, cols: size.cols, rows: size.rows, cwd: options.cwd, env });
21
+ }
22
+ catch (error) {
23
+ throw new TerminalStartError('spawn-failed', `无法启动 Shell:${error instanceof Error ? error.message : String(error)}`);
24
+ }
25
+ let exited = false;
26
+ proc.onData((data) => {
27
+ if (!exited)
28
+ events.onData(data);
29
+ });
30
+ proc.onExit(({ exitCode, signal }) => {
31
+ if (exited)
32
+ return;
33
+ exited = true;
34
+ events.onExit({ code: exitCode, ...(signal ? { signal } : {}) });
35
+ });
36
+ return {
37
+ write(data) {
38
+ if (!exited)
39
+ proc.write(data.toString('utf8'));
40
+ },
41
+ resize({ cols, rows }) {
42
+ if (!exited)
43
+ proc.resize(cols, rows);
44
+ },
45
+ kill() {
46
+ if (exited)
47
+ return;
48
+ try {
49
+ proc.kill();
50
+ }
51
+ catch {
52
+ /* already gone */
53
+ }
54
+ },
55
+ pause() {
56
+ proc.pause?.();
57
+ },
58
+ resume() {
59
+ proc.resume?.();
60
+ },
61
+ };
62
+ }
63
+ /** Connects and opens an interactive shell. Resolves once the channel is open. */
64
+ export function startSshBackend(ssh, target, credentials, size, events, options = {}) {
65
+ return new Promise((resolve, reject) => {
66
+ const client = new ssh.Client();
67
+ let channel;
68
+ let settled = false;
69
+ let finished = false;
70
+ let fingerprintMismatch = false;
71
+ let exit = { code: null };
72
+ const finish = () => {
73
+ if (finished)
74
+ return;
75
+ finished = true;
76
+ try {
77
+ client.end();
78
+ }
79
+ catch {
80
+ /* ignore */
81
+ }
82
+ if (settled)
83
+ events.onExit(exit);
84
+ };
85
+ const fail = (error) => {
86
+ if (settled)
87
+ return;
88
+ settled = true;
89
+ finished = true;
90
+ try {
91
+ client.end();
92
+ }
93
+ catch {
94
+ /* ignore */
95
+ }
96
+ reject(error);
97
+ };
98
+ client.on('error', (error) => {
99
+ if (!settled) {
100
+ fail(fingerprintMismatch
101
+ ? new TerminalStartError('host-fingerprint-mismatch', `SSH 主机指纹与配置不符(${target.name})`)
102
+ : new TerminalStartError('ssh-connect-failed', `SSH 连接失败(${target.name}):${error.message}`));
103
+ return;
104
+ }
105
+ finish();
106
+ });
107
+ client.on('close', () => {
108
+ if (!settled) {
109
+ fail(fingerprintMismatch
110
+ ? new TerminalStartError('host-fingerprint-mismatch', `SSH 主机指纹与配置不符(${target.name})`)
111
+ : new TerminalStartError('ssh-connect-failed', `SSH 连接已关闭(${target.name})`));
112
+ return;
113
+ }
114
+ finish();
115
+ });
116
+ client.on('ready', () => {
117
+ client.shell({ term: TERM, cols: size.cols, rows: size.rows }, (error, opened) => {
118
+ if (error) {
119
+ fail(new TerminalStartError('ssh-shell-failed', `无法打开远程 Shell(${target.name}):${error.message}`));
120
+ return;
121
+ }
122
+ if (finished) {
123
+ opened.close?.();
124
+ return;
125
+ }
126
+ channel = opened;
127
+ opened.on('data', (data) => {
128
+ if (!finished)
129
+ events.onData(data);
130
+ });
131
+ opened.stderr?.on('data', (data) => {
132
+ if (!finished)
133
+ events.onData(data);
134
+ });
135
+ opened.on('exit', (code, signal) => {
136
+ exit = { code: code ?? null, ...(signal ? { signal } : {}) };
137
+ });
138
+ opened.on('close', finish);
139
+ settled = true;
140
+ resolve({
141
+ write(data) {
142
+ if (!finished)
143
+ opened.write(data);
144
+ },
145
+ resize({ cols, rows }) {
146
+ if (!finished)
147
+ opened.setWindow(rows, cols, 0, 0);
148
+ },
149
+ kill() {
150
+ if (finished)
151
+ return;
152
+ try {
153
+ channel?.close?.();
154
+ }
155
+ catch {
156
+ /* ignore */
157
+ }
158
+ finish();
159
+ },
160
+ pause() {
161
+ opened.pause?.();
162
+ },
163
+ resume() {
164
+ opened.resume?.();
165
+ },
166
+ });
167
+ });
168
+ });
169
+ try {
170
+ client.connect({
171
+ host: target.host,
172
+ port: target.port,
173
+ username: target.username,
174
+ ...credentials,
175
+ readyTimeout: options.readyTimeoutMs ?? 20_000,
176
+ keepaliveInterval: 30_000,
177
+ hostVerifier: (key) => {
178
+ const observed = hostKeyFingerprint(key);
179
+ options.onHostKey?.(observed);
180
+ // Deny by default: only an explicit opt-out skips the pin.
181
+ if (target.insecureAcceptAnyHostKey !== true && observed !== target.hostFingerprint) {
182
+ fingerprintMismatch = true;
183
+ return false;
184
+ }
185
+ return true;
186
+ },
187
+ });
188
+ }
189
+ catch (error) {
190
+ fail(new TerminalStartError('ssh-connect-failed', `SSH 连接失败(${target.name}):${error instanceof Error ? error.message : String(error)}`));
191
+ }
192
+ });
193
+ }
@@ -0,0 +1,63 @@
1
+ /** Host top-level `console.terminal` block (ADR 0051). */
2
+ export interface TerminalSshTargetConfig {
3
+ readonly name: string;
4
+ readonly host: string;
5
+ readonly port: number;
6
+ readonly username: string;
7
+ readonly label?: string;
8
+ readonly privateKeyPath?: string;
9
+ readonly privateKeyEnv?: string;
10
+ readonly passwordEnv?: string;
11
+ readonly passphraseEnv?: string;
12
+ /** Normalized `SHA256:<base64 without padding>`; required unless {@link insecureAcceptAnyHostKey}. */
13
+ readonly hostFingerprint?: string;
14
+ /** Explicit opt-out of host key verification (MITM-able); the Console marks the target unsafe. */
15
+ readonly insecureAcceptAnyHostKey?: true;
16
+ }
17
+ export interface TerminalConfig {
18
+ readonly enabled: boolean;
19
+ readonly shell: string;
20
+ /** Absolute working directory for local shells. */
21
+ readonly cwd: string;
22
+ /** Seconds without input before the session is closed. */
23
+ readonly idleTimeout: number;
24
+ readonly maxSessions: number;
25
+ readonly ssh: readonly TerminalSshTargetConfig[];
26
+ }
27
+ export declare class TerminalConfigError extends Error {
28
+ constructor(path: string, message: string);
29
+ }
30
+ export interface ParseTerminalConfigOptions {
31
+ readonly projectRoot: string;
32
+ readonly platform?: NodeJS.Platform;
33
+ readonly env?: NodeJS.ProcessEnv;
34
+ }
35
+ export declare function defaultShell(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string;
36
+ /** Parses and validates the raw `console.terminal` value. Missing or `enabled: false` → disabled. */
37
+ export declare function parseTerminalConfig(raw: unknown, options: ParseTerminalConfigOptions): TerminalConfig;
38
+ /** `SHA256:abc=` / `sha256:abc` → `SHA256:abc`; undefined when not a SHA256 base64 fingerprint. */
39
+ export declare function normalizeFingerprint(value: string): string | undefined;
40
+ /** OpenSSH-style fingerprint of a raw host key blob. */
41
+ export declare function hostKeyFingerprint(key: Uint8Array): string;
42
+ export interface SshCredentials {
43
+ readonly privateKey?: Buffer | string;
44
+ readonly passphrase?: string;
45
+ readonly password?: string;
46
+ }
47
+ export declare class SshCredentialError extends Error {
48
+ constructor(message: string);
49
+ }
50
+ export interface CredentialSources {
51
+ readonly env?: NodeJS.ProcessEnv;
52
+ readonly projectRoot: string;
53
+ readonly readFile?: (path: string) => Promise<Buffer>;
54
+ readonly homedir?: () => string;
55
+ }
56
+ export declare function resolveKeyPath(path: string, projectRoot: string, home?: () => string): string;
57
+ /**
58
+ * Loads SSH credentials from env / local key file. Error messages name the
59
+ * variable or file, never its value.
60
+ */
61
+ export declare function resolveSshCredentials(target: TerminalSshTargetConfig, sources: CredentialSources): Promise<SshCredentials>;
62
+ /** Cheap availability check for the targets list (no file contents are read). */
63
+ export declare function sshCredentialProblem(target: TerminalSshTargetConfig, env: NodeJS.ProcessEnv): string | undefined;
@@ -0,0 +1,237 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, resolve } from 'node:path';
5
+ export class TerminalConfigError extends Error {
6
+ constructor(path, message) {
7
+ super(`console.terminal${path ? `.${path}` : ''}: ${message}`);
8
+ this.name = 'TerminalConfigError';
9
+ }
10
+ }
11
+ const TARGET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
12
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
13
+ const KNOWN_KEYS = new Set(['enabled', 'shell', 'cwd', 'idleTimeout', 'maxSessions', 'ssh']);
14
+ const KNOWN_SSH_KEYS = new Set([
15
+ 'name', 'label', 'host', 'port', 'username', 'privateKeyPath', 'privateKeyEnv',
16
+ 'passwordEnv', 'passphraseEnv', 'hostFingerprint', 'insecureAcceptAnyHostKey',
17
+ ]);
18
+ const FINGERPRINT_HINT = 'run `ssh-keyscan -t ed25519 <host> | ssh-keygen -lf -` on a trusted network and copy the SHA256:… value';
19
+ export function defaultShell(platform, env) {
20
+ if (platform === 'win32')
21
+ return 'powershell.exe';
22
+ return env.SHELL?.trim() || '/bin/sh';
23
+ }
24
+ /** Parses and validates the raw `console.terminal` value. Missing or `enabled: false` → disabled. */
25
+ export function parseTerminalConfig(raw, options) {
26
+ const platform = options.platform ?? process.platform;
27
+ const env = options.env ?? process.env;
28
+ if (raw === undefined || raw === null)
29
+ return disabledConfig(options.projectRoot, platform, env);
30
+ const value = record(raw, '');
31
+ for (const key of Object.keys(value)) {
32
+ if (!KNOWN_KEYS.has(key))
33
+ throw new TerminalConfigError(key, 'unknown key');
34
+ }
35
+ const enabled = optionalBoolean(value.enabled, 'enabled') ?? false;
36
+ const shell = optionalString(value.shell, 'shell') ?? defaultShell(platform, env);
37
+ const cwdRaw = optionalString(value.cwd, 'cwd') ?? '.';
38
+ const idleTimeout = optionalInteger(value.idleTimeout, 'idleTimeout', 0, 7 * 24 * 3600) ?? 900;
39
+ const maxSessions = optionalInteger(value.maxSessions, 'maxSessions', 1, 256) ?? 4;
40
+ const sshRaw = value.ssh ?? [];
41
+ if (!Array.isArray(sshRaw))
42
+ throw new TerminalConfigError('ssh', 'must be an array');
43
+ const names = new Set();
44
+ const ssh = sshRaw.map((entry, index) => {
45
+ const target = parseSshTarget(entry, `ssh[${index}]`);
46
+ if (names.has(target.name))
47
+ throw new TerminalConfigError(`ssh[${index}].name`, `duplicate target "${target.name}"`);
48
+ names.add(target.name);
49
+ return target;
50
+ });
51
+ return Object.freeze({
52
+ enabled,
53
+ shell,
54
+ cwd: resolve(options.projectRoot, cwdRaw),
55
+ idleTimeout,
56
+ maxSessions,
57
+ ssh: Object.freeze(ssh),
58
+ });
59
+ }
60
+ function disabledConfig(projectRoot, platform, env) {
61
+ return Object.freeze({
62
+ enabled: false,
63
+ shell: defaultShell(platform, env),
64
+ cwd: resolve(projectRoot),
65
+ idleTimeout: 900,
66
+ maxSessions: 4,
67
+ ssh: Object.freeze([]),
68
+ });
69
+ }
70
+ function parseSshTarget(raw, path) {
71
+ const value = record(raw, path);
72
+ for (const key of Object.keys(value)) {
73
+ if (!KNOWN_SSH_KEYS.has(key))
74
+ throw new TerminalConfigError(`${path}.${key}`, 'unknown key');
75
+ }
76
+ const name = requiredString(value.name, `${path}.name`);
77
+ if (!TARGET_NAME.test(name) || name === 'local') {
78
+ throw new TerminalConfigError(`${path}.name`, 'must match [A-Za-z0-9._-] (max 64) and must not be "local"');
79
+ }
80
+ const host = requiredString(value.host, `${path}.host`);
81
+ const port = optionalInteger(value.port, `${path}.port`, 1, 65535) ?? 22;
82
+ const username = requiredString(value.username, `${path}.username`);
83
+ const label = optionalString(value.label, `${path}.label`);
84
+ const privateKeyPath = optionalString(value.privateKeyPath, `${path}.privateKeyPath`);
85
+ const privateKeyEnv = optionalEnvName(value.privateKeyEnv, `${path}.privateKeyEnv`);
86
+ const passwordEnv = optionalEnvName(value.passwordEnv, `${path}.passwordEnv`);
87
+ const passphraseEnv = optionalEnvName(value.passphraseEnv, `${path}.passphraseEnv`);
88
+ if (privateKeyPath && privateKeyEnv) {
89
+ throw new TerminalConfigError(path, 'privateKeyPath and privateKeyEnv are mutually exclusive');
90
+ }
91
+ if (!privateKeyPath && !privateKeyEnv && !passwordEnv) {
92
+ throw new TerminalConfigError(path, 'one of privateKeyPath, privateKeyEnv or passwordEnv is required');
93
+ }
94
+ const fingerprintRaw = optionalString(value.hostFingerprint, `${path}.hostFingerprint`);
95
+ const hostFingerprint = fingerprintRaw === undefined ? undefined : normalizeFingerprint(fingerprintRaw);
96
+ if (fingerprintRaw !== undefined && !hostFingerprint) {
97
+ throw new TerminalConfigError(`${path}.hostFingerprint`, `must be "SHA256:<base64>"; ${FINGERPRINT_HINT}`);
98
+ }
99
+ const insecure = optionalBoolean(value.insecureAcceptAnyHostKey, `${path}.insecureAcceptAnyHostKey`) ?? false;
100
+ if (insecure && hostFingerprint) {
101
+ throw new TerminalConfigError(path, 'hostFingerprint and insecureAcceptAnyHostKey are mutually exclusive');
102
+ }
103
+ if (!hostFingerprint && !insecure) {
104
+ throw new TerminalConfigError(`${path}.hostFingerprint`, `is required to verify the SSH host key; ${FINGERPRINT_HINT} (or set insecureAcceptAnyHostKey: true to accept any key, unsafe)`);
105
+ }
106
+ return Object.freeze({
107
+ name, host, port, username,
108
+ ...(label ? { label } : {}),
109
+ ...(privateKeyPath ? { privateKeyPath } : {}),
110
+ ...(privateKeyEnv ? { privateKeyEnv } : {}),
111
+ ...(passwordEnv ? { passwordEnv } : {}),
112
+ ...(passphraseEnv ? { passphraseEnv } : {}),
113
+ ...(hostFingerprint ? { hostFingerprint } : {}),
114
+ ...(insecure ? { insecureAcceptAnyHostKey: true } : {}),
115
+ });
116
+ }
117
+ /** `SHA256:abc=` / `sha256:abc` → `SHA256:abc`; undefined when not a SHA256 base64 fingerprint. */
118
+ export function normalizeFingerprint(value) {
119
+ const match = /^sha256:([A-Za-z0-9+/]+)=*$/iu.exec(value.trim());
120
+ if (!match)
121
+ return undefined;
122
+ const body = match[1];
123
+ // A SHA-256 digest is 32 bytes → 43 base64 chars without padding.
124
+ if (body.length !== 43)
125
+ return undefined;
126
+ return `SHA256:${body}`;
127
+ }
128
+ /** OpenSSH-style fingerprint of a raw host key blob. */
129
+ export function hostKeyFingerprint(key) {
130
+ return `SHA256:${createHash('sha256').update(key).digest('base64').replace(/=+$/u, '')}`;
131
+ }
132
+ export class SshCredentialError extends Error {
133
+ constructor(message) {
134
+ super(message);
135
+ this.name = 'SshCredentialError';
136
+ }
137
+ }
138
+ export function resolveKeyPath(path, projectRoot, home = homedir) {
139
+ if (path === '~')
140
+ return home();
141
+ if (path.startsWith('~/') || path.startsWith('~\\'))
142
+ return resolve(home(), path.slice(2));
143
+ return isAbsolute(path) ? path : resolve(projectRoot, path);
144
+ }
145
+ /**
146
+ * Loads SSH credentials from env / local key file. Error messages name the
147
+ * variable or file, never its value.
148
+ */
149
+ export async function resolveSshCredentials(target, sources) {
150
+ const env = sources.env ?? process.env;
151
+ const read = sources.readFile ?? ((path) => readFile(path));
152
+ let privateKey;
153
+ if (target.privateKeyPath) {
154
+ const path = resolveKeyPath(target.privateKeyPath, sources.projectRoot, sources.homedir);
155
+ try {
156
+ privateKey = await read(path);
157
+ }
158
+ catch {
159
+ throw new SshCredentialError(`无法读取私钥文件 ${target.privateKeyPath}`);
160
+ }
161
+ }
162
+ else if (target.privateKeyEnv) {
163
+ const value = env[target.privateKeyEnv];
164
+ if (!value)
165
+ throw new SshCredentialError(`环境变量 ${target.privateKeyEnv} 未设置`);
166
+ privateKey = value;
167
+ }
168
+ let passphrase;
169
+ if (target.passphraseEnv) {
170
+ passphrase = env[target.passphraseEnv];
171
+ if (!passphrase)
172
+ throw new SshCredentialError(`环境变量 ${target.passphraseEnv} 未设置`);
173
+ }
174
+ let password;
175
+ if (target.passwordEnv) {
176
+ password = env[target.passwordEnv] || undefined;
177
+ // With a key configured too, the password is only a fallback.
178
+ if (!password && privateKey === undefined)
179
+ throw new SshCredentialError(`环境变量 ${target.passwordEnv} 未设置`);
180
+ }
181
+ return Object.freeze({
182
+ ...(privateKey !== undefined ? { privateKey } : {}),
183
+ ...(passphrase ? { passphrase } : {}),
184
+ ...(password ? { password } : {}),
185
+ });
186
+ }
187
+ /** Cheap availability check for the targets list (no file contents are read). */
188
+ export function sshCredentialProblem(target, env) {
189
+ if (target.privateKeyEnv && !env[target.privateKeyEnv])
190
+ return `环境变量 ${target.privateKeyEnv} 未设置`;
191
+ if (target.passphraseEnv && !env[target.passphraseEnv])
192
+ return `环境变量 ${target.passphraseEnv} 未设置`;
193
+ if (target.passwordEnv && !target.privateKeyPath && !target.privateKeyEnv && !env[target.passwordEnv]) {
194
+ return `环境变量 ${target.passwordEnv} 未设置`;
195
+ }
196
+ return undefined;
197
+ }
198
+ function record(value, path) {
199
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
200
+ throw new TerminalConfigError(path, 'must be an object');
201
+ }
202
+ return value;
203
+ }
204
+ function optionalBoolean(value, path) {
205
+ if (value === undefined)
206
+ return undefined;
207
+ if (typeof value !== 'boolean')
208
+ throw new TerminalConfigError(path, 'must be a boolean');
209
+ return value;
210
+ }
211
+ function optionalString(value, path) {
212
+ if (value === undefined)
213
+ return undefined;
214
+ if (typeof value !== 'string' || !value.trim())
215
+ throw new TerminalConfigError(path, 'must be a non-empty string');
216
+ return value.trim();
217
+ }
218
+ function requiredString(value, path) {
219
+ const result = optionalString(value, path);
220
+ if (result === undefined)
221
+ throw new TerminalConfigError(path, 'is required');
222
+ return result;
223
+ }
224
+ function optionalEnvName(value, path) {
225
+ const name = optionalString(value, path);
226
+ if (name !== undefined && !ENV_NAME.test(name))
227
+ throw new TerminalConfigError(path, 'must be an environment variable name');
228
+ return name;
229
+ }
230
+ function optionalInteger(value, path, min, max) {
231
+ if (value === undefined)
232
+ return undefined;
233
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
234
+ throw new TerminalConfigError(path, `must be an integer between ${min} and ${max}`);
235
+ }
236
+ return value;
237
+ }
@@ -0,0 +1,26 @@
1
+ import { type TokenRegistry } from '@zhin.js/host-http';
2
+ import type { ConsoleIamService } from '../iam/service.js';
3
+ import { type ConsoleTerminalHandle, type InstallConsoleTerminalOptions, type TerminalHttpHost } from './index.js';
4
+ /** The gateway's HTTP server, or an in-process `HttpHost`. */
5
+ export interface ConsoleTerminalHost extends TerminalHttpHost {
6
+ readonly tokenRegistry: TokenRegistry;
7
+ }
8
+ export interface InstallHostConsoleTerminalOptions {
9
+ /** Raw host config `console.terminal`. */
10
+ readonly config: unknown;
11
+ readonly projectRoot: string;
12
+ readonly base?: string;
13
+ /** IAM service of this process; `undefined` when the database cannot hold accounts. */
14
+ readonly iam: () => ConsoleIamService | undefined;
15
+ readonly logger?: InstallConsoleTerminalOptions['logger'];
16
+ /** Test seams forwarded to {@link installConsoleTerminal}. */
17
+ readonly modules?: InstallConsoleTerminalOptions['modules'];
18
+ readonly env?: NodeJS.ProcessEnv;
19
+ }
20
+ /**
21
+ * Wires the Console terminal (ADR 0051) to Console access control (ADR 0049): only user
22
+ * sessions and user API tokens holding a global `ops.terminal` may use it (the bootstrap
23
+ * token never), terminal events land in the IAM audit log, and revoking a user's
24
+ * credentials ends their terminal sessions.
25
+ */
26
+ export declare function installHostConsoleTerminal(http: ConsoleTerminalHost, options: InstallHostConsoleTerminalOptions): ConsoleTerminalHandle;