@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,455 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { startLocalBackend, startSshBackend, TerminalStartError, } from './backends.js';
3
+ import { resolveSshCredentials, sshCredentialProblem, } from './config.js';
4
+ import { createModuleCache } from './modules.js';
5
+ import { TerminalTicketStore } from './tickets.js';
6
+ export class TerminalRequestError extends Error {
7
+ status;
8
+ code;
9
+ constructor(status, code, message) {
10
+ super(message);
11
+ this.status = status;
12
+ this.code = code;
13
+ this.name = 'TerminalRequestError';
14
+ }
15
+ }
16
+ export const TERMINAL_CLOSE_CODES = Object.freeze({
17
+ normal: 1000,
18
+ startFailed: 1011,
19
+ badRequest: 4400,
20
+ invalidTicket: 4401,
21
+ forbidden: 4403,
22
+ tooManySessions: 4429,
23
+ });
24
+ const MAX_DIMENSION = 1000;
25
+ const MAX_TEXT_FRAME = 4096;
26
+ const MAX_INPUT_FRAME = 1 << 20;
27
+ /** Input queued while the session starts; frames beyond it are dropped. */
28
+ const MAX_PENDING_INPUT = 1 << 20;
29
+ // Output backpressure on the socket's send buffer.
30
+ const PAUSE_BUFFERED = 256 * 1024;
31
+ const RESUME_BUFFERED = 64 * 1024;
32
+ const RESUME_POLL_MS = 25;
33
+ /** Close code of a connection that dropped without a close frame. */
34
+ const ABNORMAL_CLOSURE = 1006;
35
+ /** Terminal tickets, targets and live sessions (ADR 0051). Transport-agnostic. */
36
+ export class TerminalService {
37
+ #options;
38
+ #tickets;
39
+ #modules;
40
+ #sessions = new Map();
41
+ #env;
42
+ #now;
43
+ #closed = false;
44
+ constructor(options) {
45
+ this.#options = options;
46
+ this.#now = options.now ?? Date.now;
47
+ this.#tickets = new TerminalTicketStore({ now: this.#now });
48
+ this.#modules = createModuleCache(options.modules);
49
+ this.#env = options.env ?? process.env;
50
+ }
51
+ get config() {
52
+ return this.#options.config;
53
+ }
54
+ /** Throws `TerminalRequestError(403)` unless the principal may use the terminal. */
55
+ async requireAuthorized(principal, ip) {
56
+ if (!principal) {
57
+ this.#audit({ action: 'terminal.denied', actor: 'anonymous', target: '', outcome: 'denied', ip, detail: { reason: 'unauthenticated' } });
58
+ throw new TerminalRequestError(401, 'unauthorized', '需要登录');
59
+ }
60
+ let allowed = false;
61
+ try {
62
+ allowed = (await this.#options.authorize(principal)) === true;
63
+ }
64
+ catch {
65
+ allowed = false;
66
+ }
67
+ if (!allowed) {
68
+ this.#audit({ action: 'terminal.denied', actor: principal.id, target: '', outcome: 'denied', ip, detail: { reason: 'forbidden' } });
69
+ throw new TerminalRequestError(403, 'forbidden', '需要 ops.terminal 权限');
70
+ }
71
+ return principal;
72
+ }
73
+ async listTargets() {
74
+ const pty = await this.#modules.pty();
75
+ const targets = [{
76
+ id: 'local',
77
+ kind: 'local',
78
+ label: '本机 Shell',
79
+ available: pty.ok,
80
+ ...(pty.ok ? {} : { reason: pty.reason }),
81
+ }];
82
+ if (this.config.ssh.length === 0)
83
+ return targets;
84
+ const ssh = await this.#modules.ssh();
85
+ for (const target of this.config.ssh) {
86
+ const problem = ssh.ok ? sshCredentialProblem(target, this.#env) : ssh.reason;
87
+ targets.push({
88
+ id: target.name,
89
+ kind: 'ssh',
90
+ label: `${target.label ?? sshLabel(target)}${target.insecureAcceptAnyHostKey ? '(不安全:未校验主机密钥)' : ''}`,
91
+ available: problem === undefined,
92
+ ...(problem === undefined ? {} : { reason: problem }),
93
+ ...(target.insecureAcceptAnyHostKey ? { insecureHostKey: true } : {}),
94
+ });
95
+ }
96
+ return targets;
97
+ }
98
+ async issueTicket(principal, body, ip) {
99
+ if (this.#closed)
100
+ throw new TerminalRequestError(404, 'not-found', '终端未启用');
101
+ const input = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
102
+ const targetId = typeof input.target === 'string' ? input.target : '';
103
+ const size = parseSize(input.cols ?? 80, input.rows ?? 24);
104
+ if (!size)
105
+ throw new TerminalRequestError(400, 'bad-request', `cols / rows 必须是 1–${MAX_DIMENSION} 的整数`);
106
+ const target = (await this.listTargets()).find((entry) => entry.id === targetId);
107
+ if (!target)
108
+ throw new TerminalRequestError(404, 'unknown-target', '未知的终端目标');
109
+ if (!target.available)
110
+ throw new TerminalRequestError(409, 'unavailable', target.reason ?? '终端目标不可用');
111
+ if (this.#sessions.size >= this.config.maxSessions) {
112
+ throw new TerminalRequestError(429, 'too-many-sessions', `终端会话已达上限(${this.config.maxSessions})`);
113
+ }
114
+ const issued = this.#tickets.issue({ principalId: principal.id, principal, target: target.id, ...size });
115
+ this.#audit({ action: 'terminal.ticket.issue', actor: principal.id, target: target.id, outcome: 'ok', ip, detail: {} });
116
+ return issued;
117
+ }
118
+ /**
119
+ * Handles one accepted WebSocket. `presentedPrincipal` is set when the upgrade
120
+ * also carried a bearer credential; it must then match the ticket's principal.
121
+ */
122
+ async handleConnection(socket, presentedPrincipal) {
123
+ const ip = socket.remoteAddress;
124
+ // Listen before any await: the peer may leave while authorization or startup is pending.
125
+ // Frames that arrive before the shell is up (ticket check, PTY / SSH start) are queued and
126
+ // replayed once it is ready: a client may type or paste right after the socket opens.
127
+ const connection = {
128
+ gone: false, pending: [], pendingBytes: 0,
129
+ };
130
+ const open = () => !connection.gone;
131
+ socket.onClose((code) => {
132
+ connection.gone = true;
133
+ connection.pending = [];
134
+ connection.session?.end(code === ABNORMAL_CLOSURE ? 'error' : 'disconnect');
135
+ });
136
+ socket.onMessage((data) => {
137
+ if (connection.session?.backend) {
138
+ this.#onMessage(connection.session, data, typeof data !== 'string');
139
+ return;
140
+ }
141
+ const bytes = typeof data === 'string' ? Buffer.byteLength(data) : data.byteLength;
142
+ // Bounded: a peer cannot grow memory before its session exists.
143
+ if (connection.pendingBytes + bytes > MAX_PENDING_INPUT)
144
+ return;
145
+ connection.pendingBytes += bytes;
146
+ connection.pending.push({ data, bytes });
147
+ });
148
+ const ticket = socket.url.searchParams.get('ticket');
149
+ const consumed = this.#tickets.consume(ticket, presentedPrincipal?.id);
150
+ if (!consumed.ok) {
151
+ this.#audit({ action: 'terminal.denied', actor: presentedPrincipal?.id ?? 'anonymous', target: '', outcome: 'denied', ip, detail: { reason: `ticket-${consumed.reason}` } });
152
+ reject(socket, TERMINAL_CLOSE_CODES.invalidTicket, 'invalid-ticket', '终端票据无效或已过期', open);
153
+ return;
154
+ }
155
+ const { principal, target: targetId, cols, rows } = consumed.grant;
156
+ try {
157
+ // Re-check: the principal may have been revoked since the ticket was issued.
158
+ await this.requireAuthorized(principal, ip);
159
+ }
160
+ catch {
161
+ reject(socket, TERMINAL_CLOSE_CODES.forbidden, 'forbidden', '需要 ops.terminal 权限', open);
162
+ return;
163
+ }
164
+ if (this.#closed) {
165
+ reject(socket, TERMINAL_CLOSE_CODES.forbidden, 'disabled', '终端未启用', open);
166
+ return;
167
+ }
168
+ if (connection.gone)
169
+ return;
170
+ if (this.#sessions.size >= this.config.maxSessions) {
171
+ this.#audit({ action: 'terminal.denied', actor: principal.id, target: targetId, outcome: 'denied', ip, detail: { reason: 'too-many-sessions' } });
172
+ reject(socket, TERMINAL_CLOSE_CODES.tooManySessions, 'too-many-sessions', `终端会话已达上限(${this.config.maxSessions})`, open);
173
+ return;
174
+ }
175
+ const sshTarget = targetId === 'local' ? undefined : this.config.ssh.find((entry) => entry.name === targetId);
176
+ if (targetId !== 'local' && !sshTarget) {
177
+ reject(socket, TERMINAL_CLOSE_CODES.badRequest, 'unknown-target', '未知的终端目标', open);
178
+ return;
179
+ }
180
+ const kind = sshTarget ? 'ssh' : 'local';
181
+ const info = Object.freeze({
182
+ sessionId: randomUUID(),
183
+ principalId: principal.id,
184
+ target: targetId,
185
+ kind,
186
+ startedAt: this.#now(),
187
+ });
188
+ const live = this.#createSession(info, principal, socket, open, ip);
189
+ connection.session = live;
190
+ this.#sessions.set(info.sessionId, live);
191
+ let resumeTimer;
192
+ const resumeWhenDrained = () => {
193
+ if (live.ended || !open()) {
194
+ clearInterval(resumeTimer);
195
+ resumeTimer = undefined;
196
+ return;
197
+ }
198
+ if (socket.bufferedAmount > RESUME_BUFFERED)
199
+ return;
200
+ clearInterval(resumeTimer);
201
+ resumeTimer = undefined;
202
+ live.backend?.resume();
203
+ };
204
+ const events = {
205
+ onData: (chunk) => {
206
+ if (live.ended || !open())
207
+ return;
208
+ // Output is always a binary frame; control messages are text.
209
+ socket.send(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk);
210
+ if (!resumeTimer && socket.bufferedAmount >= PAUSE_BUFFERED) {
211
+ live.backend?.pause();
212
+ resumeTimer = setInterval(resumeWhenDrained, RESUME_POLL_MS);
213
+ resumeTimer.unref?.();
214
+ }
215
+ },
216
+ onExit: (exit) => live.end('process-exit', exit),
217
+ };
218
+ let backend;
219
+ try {
220
+ backend = sshTarget
221
+ ? await this.#startSsh(sshTarget, { cols, rows }, events)
222
+ : await this.#startLocal({ cols, rows }, events);
223
+ }
224
+ catch (error) {
225
+ const code = error instanceof TerminalStartError ? error.code : 'start-failed';
226
+ // Only our own start errors are described to the client; others stay in the log.
227
+ const message = error instanceof TerminalStartError ? error.message : '终端启动失败';
228
+ this.#sessions.delete(info.sessionId);
229
+ live.ended = true;
230
+ this.#audit({ action: 'terminal.session.start', actor: principal.id, target: targetId, outcome: 'error', ip, detail: { sessionId: info.sessionId, kind, code } });
231
+ this.#options.logger?.warn?.(`terminal start failed | target=${targetId} | code=${code}`);
232
+ reject(socket, TERMINAL_CLOSE_CODES.startFailed, code, message, open);
233
+ return;
234
+ }
235
+ if (live.ended) {
236
+ // Disconnected (or revoked) while starting.
237
+ backend.kill();
238
+ return;
239
+ }
240
+ live.backend = backend;
241
+ this.#audit({ action: 'terminal.session.start', actor: principal.id, target: targetId, outcome: 'ok', ip, detail: { sessionId: info.sessionId, kind } });
242
+ sendControl(socket, { type: 'ready', sessionId: info.sessionId, target: targetId, kind, cols, rows }, open);
243
+ this.#resetIdle(live);
244
+ const queued = connection.pending;
245
+ connection.pending = [];
246
+ connection.pendingBytes = 0;
247
+ for (const frame of queued)
248
+ this.#onMessage(live, frame.data, typeof frame.data !== 'string');
249
+ }
250
+ /** Ends every session (and pending ticket) of a principal. Returns the number of sessions ended. */
251
+ terminatePrincipal(principalId, reason = 'revoked') {
252
+ this.#tickets.revokePrincipal(principalId);
253
+ let count = 0;
254
+ for (const session of [...this.#sessions.values()]) {
255
+ if (session.principal.id !== principalId)
256
+ continue;
257
+ session.end(reason);
258
+ count += 1;
259
+ }
260
+ return count;
261
+ }
262
+ /** Ends all sessions; with `final`, refuses further tickets and connections. */
263
+ closeAll(reason = 'disabled', final = false) {
264
+ if (final)
265
+ this.#closed = true;
266
+ this.#tickets.clear();
267
+ for (const session of [...this.#sessions.values()])
268
+ session.end(reason);
269
+ }
270
+ sessions() {
271
+ return [...this.#sessions.values()].map((session) => session.info);
272
+ }
273
+ #createSession(info, principal, socket, open, ip) {
274
+ const session = {
275
+ info,
276
+ principal,
277
+ socket,
278
+ open,
279
+ ...(ip ? { ip } : {}),
280
+ ended: false,
281
+ end: (reason, exit) => {
282
+ if (session.ended)
283
+ return;
284
+ session.ended = true;
285
+ if (session.idleTimer)
286
+ clearTimeout(session.idleTimer);
287
+ this.#sessions.delete(info.sessionId);
288
+ session.backend?.kill();
289
+ if (open()) {
290
+ sendControl(socket, {
291
+ type: 'exit',
292
+ reason,
293
+ code: exit?.code ?? null,
294
+ ...(exit?.signal !== undefined ? { signal: exit.signal } : {}),
295
+ }, open);
296
+ try {
297
+ socket.close(TERMINAL_CLOSE_CODES.normal, reason);
298
+ }
299
+ catch {
300
+ /* already closing */
301
+ }
302
+ }
303
+ if (!session.backend)
304
+ return; // never started; start failure is audited separately
305
+ this.#audit({
306
+ action: 'terminal.session.end',
307
+ actor: principal.id,
308
+ target: info.target,
309
+ outcome: 'ok',
310
+ ip,
311
+ detail: {
312
+ sessionId: info.sessionId,
313
+ kind: info.kind,
314
+ durationMs: Math.max(0, this.#now() - info.startedAt),
315
+ exitCode: exit?.code ?? null,
316
+ reason,
317
+ },
318
+ });
319
+ },
320
+ };
321
+ return session;
322
+ }
323
+ #onMessage(session, data, isBinary) {
324
+ if (session.ended || !session.backend)
325
+ return;
326
+ const buffer = toBuffer(data);
327
+ if (!buffer)
328
+ return;
329
+ if (isBinary) {
330
+ if (buffer.length > MAX_INPUT_FRAME) {
331
+ sendControl(session.socket, { type: 'error', code: 'frame-too-large', message: '输入帧过大' }, session.open);
332
+ return;
333
+ }
334
+ this.#resetIdle(session);
335
+ session.backend.write(buffer);
336
+ return;
337
+ }
338
+ if (buffer.length > MAX_TEXT_FRAME) {
339
+ sendControl(session.socket, { type: 'error', code: 'bad-message', message: '控制消息过大' }, session.open);
340
+ return;
341
+ }
342
+ let message;
343
+ try {
344
+ const parsed = JSON.parse(buffer.toString('utf8'));
345
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
346
+ throw new Error('not an object');
347
+ message = parsed;
348
+ }
349
+ catch {
350
+ sendControl(session.socket, { type: 'error', code: 'bad-message', message: '控制消息不是 JSON 对象' }, session.open);
351
+ return;
352
+ }
353
+ if (message.type === 'resize') {
354
+ const size = parseSize(message.cols, message.rows);
355
+ if (!size) {
356
+ sendControl(session.socket, { type: 'error', code: 'bad-message', message: `cols / rows 必须是 1–${MAX_DIMENSION} 的整数` }, session.open);
357
+ return;
358
+ }
359
+ session.backend.resize(size);
360
+ return;
361
+ }
362
+ if (message.type === 'ping') {
363
+ // Keep-alive only: does not reset the idle timer (idle means no input).
364
+ sendControl(session.socket, { type: 'pong' }, session.open);
365
+ return;
366
+ }
367
+ sendControl(session.socket, { type: 'error', code: 'bad-message', message: '未知的控制消息' }, session.open);
368
+ }
369
+ #resetIdle(session) {
370
+ const seconds = this.config.idleTimeout;
371
+ if (seconds <= 0)
372
+ return;
373
+ if (session.idleTimer)
374
+ clearTimeout(session.idleTimer);
375
+ session.idleTimer = setTimeout(() => session.end('idle-timeout'), seconds * 1000);
376
+ session.idleTimer.unref?.();
377
+ }
378
+ async #startLocal(size, events) {
379
+ const pty = await this.#modules.pty();
380
+ if (!pty.ok)
381
+ throw new TerminalStartError('unavailable', pty.reason);
382
+ return startLocalBackend(pty.module, { shell: this.config.shell, cwd: this.config.cwd, env: this.#env }, size, events);
383
+ }
384
+ async #startSsh(target, size, events) {
385
+ const ssh = await this.#modules.ssh();
386
+ if (!ssh.ok)
387
+ throw new TerminalStartError('unavailable', ssh.reason);
388
+ let credentials;
389
+ try {
390
+ credentials = await resolveSshCredentials(target, {
391
+ env: this.#env,
392
+ projectRoot: this.#options.projectRoot,
393
+ ...(this.#options.readFile ? { readFile: this.#options.readFile } : {}),
394
+ });
395
+ }
396
+ catch (error) {
397
+ throw new TerminalStartError('credentials-unavailable', error instanceof Error ? error.message : String(error));
398
+ }
399
+ return startSshBackend(ssh.module, target, credentials, size, events, {
400
+ onHostKey: (fingerprint) => {
401
+ if (target.insecureAcceptAnyHostKey) {
402
+ this.#options.logger?.warn?.(`terminal ssh ${target.name} host key ${fingerprint} accepted WITHOUT verification (insecureAcceptAnyHostKey); pin it with hostFingerprint`);
403
+ }
404
+ },
405
+ });
406
+ }
407
+ #audit(event) {
408
+ try {
409
+ const { ip, ...rest } = event;
410
+ this.#options.audit?.(Object.freeze(ip ? { ...rest, ip } : rest));
411
+ }
412
+ catch (error) {
413
+ this.#options.logger?.warn?.(`terminal audit failed: ${error instanceof Error ? error.message : String(error)}`);
414
+ }
415
+ }
416
+ }
417
+ function sshLabel(target) {
418
+ return `${target.username}@${target.host}${target.port === 22 ? '' : `:${target.port}`}`;
419
+ }
420
+ function parseSize(cols, rows) {
421
+ const valid = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_DIMENSION;
422
+ return valid(cols) && valid(rows) ? { cols, rows } : undefined;
423
+ }
424
+ function toBuffer(data) {
425
+ if (Buffer.isBuffer(data))
426
+ return data;
427
+ if (data instanceof ArrayBuffer)
428
+ return Buffer.from(data);
429
+ if (ArrayBuffer.isView(data))
430
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
431
+ if (Array.isArray(data) && data.every((item) => Buffer.isBuffer(item)))
432
+ return Buffer.concat(data);
433
+ if (typeof data === 'string')
434
+ return Buffer.from(data, 'utf8');
435
+ return undefined;
436
+ }
437
+ function sendControl(socket, message, open) {
438
+ if (!open())
439
+ return;
440
+ try {
441
+ socket.send(JSON.stringify(message));
442
+ }
443
+ catch {
444
+ /* socket closing */
445
+ }
446
+ }
447
+ function reject(socket, code, errorCode, message, open) {
448
+ sendControl(socket, { type: 'error', code: errorCode, message }, open);
449
+ try {
450
+ socket.close(code, errorCode);
451
+ }
452
+ catch {
453
+ /* already closing */
454
+ }
455
+ }
@@ -0,0 +1,46 @@
1
+ export declare const TERMINAL_TICKET_TTL_MS = 30000;
2
+ export interface TerminalTicketGrant {
3
+ readonly principalId: string;
4
+ /** The full principal the session will run as; `principal.id === principalId`. */
5
+ readonly principal: {
6
+ readonly id: string;
7
+ readonly kind?: string;
8
+ };
9
+ readonly target: string;
10
+ readonly cols: number;
11
+ readonly rows: number;
12
+ }
13
+ export interface IssuedTerminalTicket {
14
+ readonly ticket: string;
15
+ /** Unix epoch milliseconds. */
16
+ readonly expiresAt: number;
17
+ }
18
+ export type TicketConsumeResult = {
19
+ readonly ok: true;
20
+ readonly grant: TerminalTicketGrant;
21
+ } | {
22
+ readonly ok: false;
23
+ readonly reason: 'invalid' | 'expired' | 'principal-mismatch';
24
+ };
25
+ /**
26
+ * One-time WebSocket tickets. Only the SHA-256 of a ticket is stored; lookup is a
27
+ * map access on that hash, so response timing does not depend on how much of a
28
+ * guessed ticket matches.
29
+ */
30
+ export declare class TerminalTicketStore {
31
+ #private;
32
+ constructor(options?: {
33
+ now?: () => number;
34
+ ttlMs?: number;
35
+ });
36
+ issue(grant: TerminalTicketGrant): IssuedTerminalTicket;
37
+ /**
38
+ * Consumes a ticket. Any presented ticket is removed on first use, even when the
39
+ * caller's principal does not match, so a leaked ticket cannot be probed twice.
40
+ */
41
+ consume(ticket: string | null | undefined, expectedPrincipalId?: string): TicketConsumeResult;
42
+ revokePrincipal(principalId: string): number;
43
+ sweep(): void;
44
+ clear(): void;
45
+ get size(): number;
46
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ export const TERMINAL_TICKET_TTL_MS = 30_000;
3
+ const MAX_PENDING_PER_PRINCIPAL = 16;
4
+ /**
5
+ * One-time WebSocket tickets. Only the SHA-256 of a ticket is stored; lookup is a
6
+ * map access on that hash, so response timing does not depend on how much of a
7
+ * guessed ticket matches.
8
+ */
9
+ export class TerminalTicketStore {
10
+ #tickets = new Map();
11
+ #now;
12
+ #ttlMs;
13
+ constructor(options = {}) {
14
+ this.#now = options.now ?? Date.now;
15
+ this.#ttlMs = options.ttlMs ?? TERMINAL_TICKET_TTL_MS;
16
+ }
17
+ issue(grant) {
18
+ this.sweep();
19
+ const pending = [...this.#tickets.entries()].filter(([, entry]) => entry.principalId === grant.principalId);
20
+ // Bound memory per principal: drop the oldest pending ticket.
21
+ if (pending.length >= MAX_PENDING_PER_PRINCIPAL) {
22
+ pending.sort((a, b) => a[1].expiresAt - b[1].expiresAt);
23
+ this.#tickets.delete(pending[0][0]);
24
+ }
25
+ const ticket = randomBytes(32).toString('base64url');
26
+ const expiresAt = this.#now() + this.#ttlMs;
27
+ this.#tickets.set(hashTicket(ticket), Object.freeze({ ...grant, expiresAt }));
28
+ return Object.freeze({ ticket, expiresAt });
29
+ }
30
+ /**
31
+ * Consumes a ticket. Any presented ticket is removed on first use, even when the
32
+ * caller's principal does not match, so a leaked ticket cannot be probed twice.
33
+ */
34
+ consume(ticket, expectedPrincipalId) {
35
+ if (!ticket || ticket.length > 128)
36
+ return { ok: false, reason: 'invalid' };
37
+ const key = hashTicket(ticket);
38
+ const entry = this.#tickets.get(key);
39
+ if (!entry)
40
+ return { ok: false, reason: 'invalid' };
41
+ this.#tickets.delete(key);
42
+ if (entry.expiresAt <= this.#now())
43
+ return { ok: false, reason: 'expired' };
44
+ if (expectedPrincipalId !== undefined && expectedPrincipalId !== entry.principalId) {
45
+ return { ok: false, reason: 'principal-mismatch' };
46
+ }
47
+ const { expiresAt: _expiresAt, ...grant } = entry;
48
+ return { ok: true, grant: Object.freeze(grant) };
49
+ }
50
+ revokePrincipal(principalId) {
51
+ let removed = 0;
52
+ for (const [key, entry] of this.#tickets) {
53
+ if (entry.principalId !== principalId)
54
+ continue;
55
+ this.#tickets.delete(key);
56
+ removed += 1;
57
+ }
58
+ return removed;
59
+ }
60
+ sweep() {
61
+ const now = this.#now();
62
+ for (const [key, entry] of this.#tickets) {
63
+ if (entry.expiresAt <= now)
64
+ this.#tickets.delete(key);
65
+ }
66
+ }
67
+ clear() {
68
+ this.#tickets.clear();
69
+ }
70
+ get size() {
71
+ return this.#tickets.size;
72
+ }
73
+ }
74
+ function hashTicket(ticket) {
75
+ return createHash('sha256').update(ticket).digest('hex');
76
+ }
@@ -0,0 +1,3 @@
1
+ /** `@zhin.js/gateway/terminal` — Console terminal (ADR 0051). */
2
+ export * from './terminal/index.js';
3
+ export * from './terminal/host.js';
@@ -0,0 +1,3 @@
1
+ /** `@zhin.js/gateway/terminal` — Console terminal (ADR 0051). */
2
+ export * from './terminal/index.js';
3
+ export * from './terminal/host.js';
@@ -0,0 +1,87 @@
1
+ export declare const DAY_MS = 86400000;
2
+ export declare const HOUR_MS = 3600000;
3
+ export declare const FIXED_SEGMENTS: ReadonlySet<string>;
4
+ export interface TraceScanOptions {
5
+ readonly since?: number;
6
+ readonly until?: number;
7
+ /** Substring, or `*` glob, matched against the session key. */
8
+ readonly session?: string;
9
+ }
10
+ export interface TraceScanResult {
11
+ /** `*.jsonl` files found in the directory. */
12
+ readonly files: number;
13
+ /** Files actually opened (not skipped by their date name). */
14
+ readonly filesRead: number;
15
+ readonly lines: number;
16
+ readonly malformed: number;
17
+ }
18
+ export declare function num(value: unknown): number | undefined;
19
+ export declare function sessionMatcher(pattern: string | undefined): (key: string) => boolean;
20
+ /**
21
+ * Streams every valid trace record in range to `visit`. A missing dir yields an
22
+ * empty scan. A record is valid with a numeric `started_at` and a string `session_key`.
23
+ */
24
+ export declare function scanTraces(dir: string, options: TraceScanOptions, visit: (trace: any, startedAt: number) => void): Promise<TraceScanResult>;
25
+ export type UsageBucket = 'hour' | 'day';
26
+ export interface UsageStatsQuery {
27
+ readonly since: number;
28
+ readonly until: number;
29
+ readonly bucket: UsageBucket;
30
+ readonly session?: string;
31
+ }
32
+ export interface UsageStatsTokens {
33
+ readonly calls: number;
34
+ readonly inputTokens: number;
35
+ readonly outputTokens: number;
36
+ readonly cacheReadTokens: number;
37
+ readonly cacheWriteTokens: number;
38
+ /** cacheReadTokens / inputTokens, 0..1 (0 without input). */
39
+ readonly cacheShare: number;
40
+ }
41
+ export interface UsageStats {
42
+ readonly range: {
43
+ readonly since: number;
44
+ readonly until: number;
45
+ readonly bucket: UsageBucket;
46
+ readonly timeZone: string;
47
+ };
48
+ readonly totals: UsageStatsTokens & {
49
+ readonly turns: number;
50
+ readonly cacheShareByCall: {
51
+ readonly p50: number;
52
+ readonly p90: number;
53
+ };
54
+ };
55
+ readonly byModel: ReadonlyArray<UsageStatsTokens & {
56
+ readonly provider: string;
57
+ readonly model: string;
58
+ }>;
59
+ readonly bySession: ReadonlyArray<UsageStatsTokens & {
60
+ readonly sessionKey: string;
61
+ readonly platform?: string;
62
+ readonly scope?: string;
63
+ readonly sceneId?: string;
64
+ readonly turns: number;
65
+ }>;
66
+ readonly series: ReadonlyArray<UsageStatsTokens & {
67
+ readonly bucketStart: number;
68
+ readonly byModel: Readonly<Record<string, number>>;
69
+ }>;
70
+ readonly segments: {
71
+ readonly history: number;
72
+ readonly fixed: number;
73
+ readonly user: number;
74
+ };
75
+ readonly filesRead: number;
76
+ readonly malformedLines: number;
77
+ }
78
+ export declare const USAGE_STATS_SESSION_LIMIT = 50;
79
+ /** Start of the local hour/day containing `ms`. */
80
+ export declare function bucketStartOf(ms: number, bucket: UsageBucket): number;
81
+ /** `platform:endpoint:scope:sceneId`; other shapes keep whatever parts they have. */
82
+ export declare function parseSessionKey(key: string): {
83
+ platform?: string;
84
+ scope?: string;
85
+ sceneId?: string;
86
+ };
87
+ export declare function buildUsageStats(dir: string, query: UsageStatsQuery): Promise<UsageStats>;