@zhin.js/host-http 1.1.16 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +60 -21
  2. package/lib/access-control.d.ts +127 -0
  3. package/lib/access-control.js +148 -0
  4. package/lib/addresses.d.ts +9 -0
  5. package/lib/addresses.js +9 -0
  6. package/lib/bridge/bridged-host.d.ts +43 -0
  7. package/lib/bridge/bridged-host.js +497 -0
  8. package/lib/bridge/control-channel.d.ts +59 -0
  9. package/lib/bridge/control-channel.js +131 -0
  10. package/lib/bridge/flow.d.ts +58 -0
  11. package/lib/bridge/flow.js +188 -0
  12. package/lib/bridge/index.d.ts +6 -0
  13. package/lib/bridge/index.js +6 -0
  14. package/lib/bridge/instance-transport.d.ts +112 -0
  15. package/lib/bridge/instance-transport.js +532 -0
  16. package/lib/bridge/protocol.d.ts +242 -0
  17. package/lib/bridge/protocol.js +369 -0
  18. package/lib/console-events.d.ts +5 -3
  19. package/lib/console-events.js +53 -27
  20. package/lib/console-plugin-rpc.d.ts +5 -1
  21. package/lib/console-plugin-rpc.js +5 -40
  22. package/lib/console-rest-pages-helpers.d.ts +4 -5
  23. package/lib/console-rest-pages-helpers.js +62 -79
  24. package/lib/console-rest-pages.js +80 -65
  25. package/lib/console-rpc-extended/dispatcher.js +1 -6
  26. package/lib/console-rpc-handlers.d.ts +0 -2
  27. package/lib/console-rpc-handlers.js +2 -40
  28. package/lib/console-rpc.d.ts +6 -14
  29. package/lib/console-rpc.js +6 -6
  30. package/lib/host-installer.d.ts +6 -3
  31. package/lib/host-installer.js +10 -40
  32. package/lib/host-options.d.ts +47 -0
  33. package/lib/host-options.js +1 -0
  34. package/lib/http-host.d.ts +124 -48
  35. package/lib/http-host.js +319 -332
  36. package/lib/index.d.ts +10 -5
  37. package/lib/index.js +9 -5
  38. package/lib/json-body.d.ts +1 -2
  39. package/lib/json-body.js +1 -2
  40. package/lib/marketplace-registry.d.ts +17 -0
  41. package/lib/marketplace-registry.js +52 -0
  42. package/lib/node/index.d.ts +10 -0
  43. package/lib/node/index.js +10 -0
  44. package/lib/{listener-group.d.ts → node/listener-group.d.ts} +2 -3
  45. package/lib/{listener-group.js → node/listener-group.js} +6 -10
  46. package/lib/node/listener.d.ts +26 -0
  47. package/lib/node/listener.js +231 -0
  48. package/lib/node/node-http.d.ts +38 -0
  49. package/lib/node/node-http.js +272 -0
  50. package/lib/node/server.d.ts +13 -0
  51. package/lib/node/server.js +64 -0
  52. package/lib/protocol-auth.d.ts +24 -0
  53. package/lib/protocol-auth.js +48 -0
  54. package/lib/public-api.d.ts +1 -1
  55. package/lib/token-registry.d.ts +54 -36
  56. package/lib/token-registry.js +133 -126
  57. package/lib/trust-proxy.d.ts +22 -0
  58. package/lib/trust-proxy.js +168 -0
  59. package/package.json +14 -6
  60. package/src/access-control.ts +255 -0
  61. package/src/addresses.ts +16 -0
  62. package/src/bridge/bridged-host.ts +548 -0
  63. package/src/bridge/control-channel.ts +173 -0
  64. package/src/bridge/flow.ts +208 -0
  65. package/src/bridge/index.ts +31 -0
  66. package/src/bridge/instance-transport.ts +627 -0
  67. package/src/bridge/protocol.ts +574 -0
  68. package/src/console-events.ts +59 -28
  69. package/src/console-plugin-rpc.ts +5 -51
  70. package/src/console-rest-pages-helpers.ts +60 -82
  71. package/src/console-rest-pages.ts +87 -67
  72. package/src/console-rpc-extended/dispatcher.ts +1 -6
  73. package/src/console-rpc-handlers.ts +2 -31
  74. package/src/console-rpc.ts +10 -19
  75. package/src/host-installer.ts +12 -44
  76. package/src/host-options.ts +47 -0
  77. package/src/http-host.ts +467 -421
  78. package/src/index.ts +55 -13
  79. package/src/json-body.ts +7 -2
  80. package/src/marketplace-registry.ts +62 -0
  81. package/src/node/index.ts +21 -0
  82. package/src/{listener-group.ts → node/listener-group.ts} +13 -24
  83. package/src/node/listener.ts +265 -0
  84. package/src/node/node-http.ts +263 -0
  85. package/src/node/server.ts +82 -0
  86. package/src/protocol-auth.ts +52 -0
  87. package/src/public-api.ts +2 -0
  88. package/src/token-registry.ts +143 -146
  89. package/src/trust-proxy.ts +178 -0
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import type { ServerResponse } from 'node:http';
2
+ import type { HttpResponseStream } from '@zhin.js/host-http-contract';
3
3
  import type {
4
4
  ConsoleEventEnvelope,
5
5
  ConsoleEventHistoryPage,
@@ -11,9 +11,13 @@ import { createToken } from '@zhin.js/plugin-runtime';
11
11
 
12
12
  /**
13
13
  * Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
14
- * 单进程内 fan-out:`publish` 向所有已订阅的 SSE response 写帧;
14
+ * 单进程内 fan-out:`publish` 向所有已订阅的 SSE 响应流写帧;
15
15
  * write 失败或连接断开自动摘除订阅。
16
16
  */
17
+
18
+ /** The SSE response body a subscriber reads from (`createResponseStream()`). */
19
+ export type ConsoleEventSink = Pick<HttpResponseStream, 'write' | 'onDrain' | 'onClose' | 'error'>;
20
+
17
21
  export interface ConsoleEventSubscriptionOptions {
18
22
  readonly runtimeId?: string;
19
23
  readonly after?: number;
@@ -31,7 +35,7 @@ export interface ConsoleEventHub {
31
35
  /** In-process delivery for trusted Host bridges such as Device Protocol. */
32
36
  listen(listener: (event: ConsoleEventEnvelope) => void): () => void;
33
37
  /** Atomically replay the resumable suffix, then join live fan-out. */
34
- subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
38
+ subscribe(sink: ConsoleEventSink, options?: ConsoleEventSubscriptionOptions): () => void;
35
39
  readonly runtimeId: string;
36
40
  readonly subscriberCount: number;
37
41
  }
@@ -70,29 +74,61 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
70
74
  );
71
75
  const journal: JournalEntry[] = [];
72
76
  let journalBytes = 0;
73
- const subscribers = new Set<ServerResponse>();
77
+ /**
78
+ * `null` while a subscriber is live. During replay catch-up it holds the frames
79
+ * (replay suffix, then live events) still waiting for the socket to drain.
80
+ */
81
+ const subscribers = new Map<ConsoleEventSink, JournalEntry[] | null>();
74
82
  const listeners = new Set<(event: ConsoleEventEnvelope) => void>();
75
83
 
76
- const remove = (response: ServerResponse): void => {
84
+ const remove = (response: ConsoleEventSink): void => {
77
85
  subscribers.delete(response);
78
86
  };
79
87
 
80
- const write = (response: ServerResponse, entry: JournalEntry): void => {
81
- let failed = false;
82
- const accepted = response.write(entry.frame, (error) => {
83
- if (error) {
84
- failed = true;
85
- remove(response);
88
+ const drop = (response: ConsoleEventSink): void => {
89
+ remove(response);
90
+ response.error(new Error('Console event subscriber backpressure limit reached'));
91
+ };
92
+
93
+ /** Returns false when the consumer asks the producer to wait for drain. */
94
+ const write = (response: ConsoleEventSink, entry: JournalEntry): boolean =>
95
+ response.write(entry.frame);
96
+
97
+ /** Write queued frames until the socket pushes back again or the queue is empty. */
98
+ const flush = (response: ConsoleEventSink): void => {
99
+ const pending = subscribers.get(response);
100
+ if (!pending) return;
101
+ try {
102
+ while (pending.length > 0) {
103
+ if (!write(response, pending.shift()!)) {
104
+ const stop = response.onDrain(() => {
105
+ stop();
106
+ flush(response);
107
+ });
108
+ return;
109
+ }
86
110
  }
87
- });
88
- if (failed) throw new Error('Console event subscriber rejected replay');
89
- if (!accepted) {
111
+ subscribers.set(response, null);
112
+ } catch {
90
113
  remove(response);
91
- response.destroy(new Error('Console event subscriber backpressure limit reached'));
92
- throw new Error('Console event subscriber backpressure limit reached');
93
114
  }
94
115
  };
95
116
 
117
+ const deliver = (response: ConsoleEventSink, entry: JournalEntry): void => {
118
+ const pending = subscribers.get(response);
119
+ if (pending === undefined) return;
120
+ if (pending === null) {
121
+ // A live subscriber that cannot keep up is disconnected; it resumes from history.
122
+ if (!write(response, entry)) drop(response);
123
+ return;
124
+ }
125
+ pending.push(entry);
126
+ // Catch-up may not outgrow what the journal itself is allowed to retain.
127
+ let bytes = 0;
128
+ for (const queued of pending) bytes += queued.bytes;
129
+ if (pending.length > historyLimit || bytes > historyByteLimit) drop(response);
130
+ };
131
+
96
132
  return {
97
133
  runtimeId,
98
134
  get subscriberCount() {
@@ -120,9 +156,9 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
120
156
  while (journal.length > historyLimit || journalBytes > historyByteLimit) {
121
157
  journalBytes -= journal.shift()?.bytes ?? 0;
122
158
  }
123
- for (const response of [...subscribers]) {
159
+ for (const response of [...subscribers.keys()]) {
124
160
  try {
125
- write(response, entry);
161
+ deliver(response, entry);
126
162
  } catch {
127
163
  remove(response);
128
164
  }
@@ -180,16 +216,11 @@ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): Con
180
216
  || requestedAfter > nextId
181
217
  ? 0
182
218
  : requestedAfter;
183
- try {
184
- for (const entry of journal) {
185
- if (entry.event.eventId > after) write(response, entry);
186
- }
187
- subscribers.add(response);
188
- } catch {
189
- remove(response);
190
- }
191
- response.once('close', () => remove(response));
192
- response.once('error', () => remove(response));
219
+ // Backpressure during replay is expected for a large suffix: the remaining frames
220
+ // (and any event published meanwhile) wait in order for the socket to drain.
221
+ subscribers.set(response, journal.filter((entry) => entry.event.eventId > after));
222
+ response.onClose(() => remove(response));
223
+ flush(response);
193
224
  return () => remove(response);
194
225
  },
195
226
  };
@@ -5,7 +5,11 @@ import type {
5
5
  RuntimeConsoleRpcReply,
6
6
  } from './console-rpc.js';
7
7
 
8
- /** Dispatches the plugin-management slice; undefined means the type belongs elsewhere. */
8
+ /**
9
+ * Dispatches the plugin-management slice; undefined means the type belongs elsewhere. Package
10
+ * installs / updates / uninstalls are not handled in the instance: they are gateway dependency
11
+ * transactions (`gateway.packages.*`, ADR 0054 §4) that stop the instance first.
12
+ */
9
13
  export async function dispatchPluginConsoleRpc(
10
14
  type: string,
11
15
  message: RuntimeConsoleRpcMessage,
@@ -71,20 +75,6 @@ export async function dispatchPluginConsoleRpc(
71
75
  if (!ctx.pluginManagement) return { requestId, error: 'Plugin management is not configured' };
72
76
  return { requestId, data: await ctx.pluginManagement.planInstall(packageName) };
73
77
  }
74
- case PLUGIN_RPC.INSTALL: {
75
- const packageName = stringField(message, 'packageName');
76
- if (!packageName) return { requestId, error: 'packageName is required' };
77
- if (!ctx.pluginManagement?.install) {
78
- return { requestId, error: 'Plugin installation is not configured' };
79
- }
80
- const result = await ctx.pluginManagement.install(
81
- packageName,
82
- optionalString(message.expectedRevision),
83
- optionalString(message.expectedProjectRevision),
84
- );
85
- ctx.publishEvent?.('plugin:installed', { packageName, restartRequired: result.restartRequired });
86
- return { requestId, data: { success: true, ...result } };
87
- }
88
78
  case PLUGIN_RPC.PLAN_UNINSTALL: {
89
79
  const packageName = stringField(message, 'packageName');
90
80
  if (!packageName) return { requestId, error: 'packageName is required' };
@@ -93,22 +83,6 @@ export async function dispatchPluginConsoleRpc(
93
83
  }
94
84
  return { requestId, data: await ctx.pluginManagement.planUninstall(packageName) };
95
85
  }
96
- case PLUGIN_RPC.UNINSTALL: {
97
- const packageName = stringField(message, 'packageName');
98
- if (!packageName || stringField(message, 'confirmation') !== packageName) {
99
- return { requestId, error: 'confirmation must exactly match packageName' };
100
- }
101
- if (!ctx.pluginManagement?.uninstall) {
102
- return { requestId, error: 'Plugin uninstall is not configured' };
103
- }
104
- const result = await ctx.pluginManagement.uninstall(
105
- packageName,
106
- optionalString(message.expectedRevision),
107
- optionalString(message.expectedProjectRevision),
108
- );
109
- ctx.publishEvent?.('plugin:uninstalled', { packageName, restartRequired: result.restartRequired });
110
- return { requestId, data: { success: true, ...result } };
111
- }
112
86
  case PLUGIN_RPC.PLAN_UPDATE: {
113
87
  const packageName = stringField(message, 'packageName');
114
88
  const targetVersion = stringField(message, 'targetVersion');
@@ -120,26 +94,6 @@ export async function dispatchPluginConsoleRpc(
120
94
  }
121
95
  return { requestId, data: await ctx.pluginManagement.planUpdate(packageName, targetVersion) };
122
96
  }
123
- case PLUGIN_RPC.UPDATE: {
124
- const packageName = stringField(message, 'packageName');
125
- const targetVersion = stringField(message, 'targetVersion');
126
- if (!packageName || !targetVersion) {
127
- return { requestId, error: 'packageName and targetVersion are required' };
128
- }
129
- if (!ctx.pluginManagement?.update) return { requestId, error: 'Plugin update is not configured' };
130
- const result = await ctx.pluginManagement.update(
131
- packageName,
132
- targetVersion,
133
- optionalString(message.expectedRevision),
134
- optionalString(message.expectedProjectRevision),
135
- );
136
- ctx.publishEvent?.('plugin:updated', {
137
- packageName,
138
- version: result.installedVersion,
139
- restartRequired: result.restartRequired,
140
- });
141
- return { requestId, data: { success: true, ...result } };
142
- }
143
97
  case PLUGIN_RPC.VALIDATE_CONFIG: {
144
98
  const pluginName = stringField(message, 'pluginName');
145
99
  if (!pluginName) return { requestId, error: 'pluginName is required' };
@@ -1,7 +1,14 @@
1
- import type { ServerResponse } from 'node:http';
2
1
  import type { HttpHost } from './http-host.js';
3
2
  import { HttpBodyError, readJsonBody } from './json-body.js';
4
- import type { AuthScope } from './token-registry.js';
3
+ import { jsonResponse } from '@zhin.js/host-http-contract';
4
+ import { formatCompact, getLogger } from '@zhin.js/logger';
5
+
6
+ const logger = getLogger('console');
7
+
8
+ /** Logs an unexpected failure server-side; clients only get a generic message. */
9
+ export function logInternalError(op: string, error: unknown): void {
10
+ logger.warn(formatCompact({ op, error: error instanceof Error ? error.message : String(error) }));
11
+ }
5
12
  import type {
6
13
  ConsoleAgentIntrospection,
7
14
  ConsoleAgentSessionTree,
@@ -125,7 +132,7 @@ export function registerIntrospectionRoutes(
125
132
  ];
126
133
 
127
134
  for (const { kind, collect, fields } of collectors) {
128
- route('GET', `${base}/introspection/${kind}`, async (_request, response, url) => {
135
+ route('GET', `${base}/introspection/${kind}`, async (_request, { url }) => {
129
136
  const lease = kind === 'endpoints' ? null : ctx.acquireAgentRuntime?.() ?? null;
130
137
  try {
131
138
  const introspection = lease?.value.introspection;
@@ -146,15 +153,16 @@ export function registerIntrospectionRoutes(
146
153
  ? 'Endpoints 数据源未接线(ctx.getEndpoints 缺失)'
147
154
  : 'Agent runtime 未装配(basic/cli 未接线 acquireAgentRuntime)'
148
155
  : undefined);
149
- writeJson(response, 200, {
156
+ return jsonResponse(200, {
150
157
  success: true,
151
158
  data: { ...slice, filter, note: degradedNote },
152
159
  });
153
160
  } catch (err) {
154
- writeJson(response, 503, {
161
+ logInternalError(`introspection_${kind}_failed`, err);
162
+ return jsonResponse(503, {
155
163
  success: false,
156
164
  data: { items: [], page: 1, pageSize: 0, total: 0, totalPages: 0 },
157
- error: err instanceof Error ? err.message : String(err),
165
+ error: 'Introspection unavailable',
158
166
  });
159
167
  } finally {
160
168
  lease?.release();
@@ -163,27 +171,25 @@ export function registerIntrospectionRoutes(
163
171
  }
164
172
 
165
173
  let activeComponentPreviews = 0;
166
- route('POST', `${base}/introspection/components/render`, async (request, response, _url, authScope) => {
167
- if (!requireWriteScope(response, ctx, authScope)) return;
174
+ route('POST', `${base}/introspection/components/render`, async (request) => {
175
+ const denied = requireWriteScope(ctx);
176
+ if (denied) return denied;
168
177
  const body = (await readJsonBody<Record<string, unknown>>(request)) ?? {};
169
178
  const requester = typeof body.requester === 'string' ? body.requester.trim() : '';
170
179
  const name = typeof body.name === 'string' ? body.name.trim() : '';
171
180
  if (!requester || !name || requester.length > 256 || name.length > 160) {
172
- writeJson(response, 400, { success: false, error: '请提供有效的 requester 与 component name' });
173
- return;
181
+ return jsonResponse(400, { success: false, error: '请提供有效的 requester 与 component name' });
174
182
  }
175
183
  const maxConcurrent = clampPositiveInt(options.componentPreviewMaxConcurrent, 2, 8);
176
184
  if (activeComponentPreviews >= maxConcurrent) {
177
- writeJson(response, 503, { success: false, error: 'Component preview 正忙,请稍后重试' });
178
- return;
185
+ return jsonResponse(503, { success: false, error: 'Component preview 正忙,请稍后重试' });
179
186
  }
180
187
  const lease = ctx.acquireAgentRuntime?.();
181
188
  const render = lease?.value.introspection?.renderComponent;
182
189
  const previewDelivery = lease?.value.introspection?.previewComponentDelivery;
183
190
  if (!lease || !render) {
184
191
  lease?.release();
185
- writeJson(response, 503, { success: false, error: 'Component Runtime 未就绪' });
186
- return;
192
+ return jsonResponse(503, { success: false, error: 'Component Runtime 未就绪' });
187
193
  }
188
194
  const controller = new AbortController();
189
195
  const timeoutMs = clampPositiveInt(options.componentPreviewTimeoutMs, 5_000, 30_000);
@@ -214,14 +220,17 @@ export function registerIntrospectionRoutes(
214
220
  controller.signal,
215
221
  );
216
222
  assertPreviewOutput(preview, options.componentPreviewMaxBytes ?? 256 * 1024);
217
- writeJson(response, 200, { success: true, data: { requester, name, ...preview } });
223
+ return jsonResponse(200, { success: true, data: { requester, name, ...preview } });
218
224
  } catch (error) {
219
225
  const status = error instanceof ComponentPreviewTimeoutError
220
226
  ? 408
221
227
  : error instanceof ComponentPreviewLimitError ? 413 : 422;
222
- writeJson(response, status, {
228
+ // Timeout / size limit messages are ours; a component's own failure detail stays in the log.
229
+ const known = error instanceof ComponentPreviewTimeoutError || error instanceof ComponentPreviewLimitError;
230
+ if (!known) logInternalError('component_preview_failed', error);
231
+ return jsonResponse(status, {
223
232
  success: false,
224
- error: error instanceof Error ? error.message : String(error),
233
+ error: known ? (error as Error).message : 'Component preview failed',
225
234
  });
226
235
  } finally {
227
236
  clearTimeout(timeout);
@@ -311,77 +320,68 @@ export function registerAgentSessionRoutes(
311
320
  }
312
321
  return { tree, release: () => lease.release() };
313
322
  };
314
- const unavailable = (response: ServerResponse) => {
315
- writeJson(response, 503, {
323
+ const unavailable = (headers?: HeadersInit) => {
324
+ return jsonResponse(503, {
316
325
  success: false,
317
326
  error: 'Agent session tree runtime 未就绪(新 Runtime 需经 acquireAgentRuntime 装配 sessionTree)',
318
- });
327
+ }, headers);
319
328
  };
320
329
 
321
330
  // HttpHost 无 `:param` 路由,用前缀路由匹配 `<sessionKey>/tree|message|leaf`。
322
- route('GET', `${base}/agent/sessions/*`, async (_request, response, url) => {
331
+ route('GET', `${base}/agent/sessions/*`, async (_request, { url }) => {
323
332
  const messageAction = parseSessionAction(url.pathname, prefix, 'message');
324
- if (messageAction) response.setHeader('Cache-Control', 'no-store');
333
+ const headers: HeadersInit | undefined = messageAction ? { 'cache-control': 'no-store' } : undefined;
334
+ const reply = (status: number, body: unknown) => jsonResponse(status, body, headers);
325
335
  const parsed = messageAction ?? parseSessionAction(url.pathname, prefix, 'tree');
326
336
  if (!parsed) {
327
- writeJson(response, 404, { success: false, error: 'Not found' });
328
- return;
337
+ return reply(404, { success: false, error: 'Not found' });
329
338
  }
330
339
  const messageId = Number(url.searchParams.get('messageId'));
331
340
  const expectedSessionId = url.searchParams.get('sessionId');
332
341
  if (messageAction && (!Number.isSafeInteger(messageId) || messageId < 1 || !expectedSessionId)) {
333
- writeJson(response, 400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
334
- return;
342
+ return reply(400, { success: false, error: '需要 sessionId 和正安全整数 messageId' });
335
343
  }
336
344
  const lease = acquireSessionTree();
337
345
  if (!lease) {
338
- unavailable(response);
339
- return;
346
+ return unavailable(headers);
340
347
  }
341
348
  const runtime = lease.tree;
342
349
  try {
343
350
  if (messageAction && !runtime.readBranchMessage) {
344
- writeJson(response, 501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
345
- return;
351
+ return reply(501, { success: false, error: '当前 Agent runtime 不支持读取完整消息' });
346
352
  }
347
353
  const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
348
354
  if (messageAction) {
349
355
  if (!sessionId) {
350
- writeJson(response, 404, { success: false, error: '未找到活跃会话' });
351
- return;
356
+ return reply(404, { success: false, error: '未找到活跃会话' });
352
357
  }
353
358
  if (sessionId !== expectedSessionId) {
354
- writeJson(response, 409, { success: false, error: '会话已变化,请重新读取会话列表' });
355
- return;
359
+ return reply(409, { success: false, error: '会话已变化,请重新读取会话列表' });
356
360
  }
357
361
  const text = await runtime.readBranchMessage!(sessionId, messageId);
358
362
  if (text === null) {
359
- writeJson(response, 404, { success: false, error: '未找到此会话的用户消息' });
360
- return;
363
+ return reply(404, { success: false, error: '未找到此会话的用户消息' });
361
364
  }
362
- writeJson(response, 200, { success: true, data: { messageId, text } });
363
- return;
365
+ return reply(200, { success: true, data: { messageId, text } });
364
366
  }
365
367
  if (!sessionId) {
366
368
  if (!isRuntimeImSessionKey(parsed.sessionKey)) {
367
- writeJson(response, 404, {
369
+ return reply(404, {
368
370
  success: false,
369
371
  error: `未找到会话:${parsed.sessionKey}`,
370
372
  });
371
- return;
372
373
  }
373
374
  const knownConversation = await ctx.isKnownConversationSession?.(parsed.sessionKey);
374
375
  if (knownConversation === false) {
375
- writeJson(response, 404, {
376
+ return reply(404, {
376
377
  success: false,
377
378
  error: `未找到会话:${parsed.sessionKey}`,
378
379
  });
379
- return;
380
380
  }
381
381
  // 渠道会话与 Agent 会话的生命周期不同:用户可以先在 Console
382
382
  // 选中一个真实渠道会话,而该会话尚未触发过 Agent。树查询是只读操作,
383
383
  // 因而将这种正常状态返回为可渲染空态,而不是误报资源不存在。
384
- writeJson(response, 200, {
384
+ return reply(200, {
385
385
  success: true,
386
386
  data: {
387
387
  state: 'not_started',
@@ -391,12 +391,11 @@ export function registerAgentSessionRoutes(
391
391
  points: [],
392
392
  },
393
393
  });
394
- return;
395
394
  }
396
395
 
397
396
  const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
398
397
  const points = await runtime.listBranchPoints(sessionId);
399
- writeJson(response, 200, {
398
+ return reply(200, {
400
399
  success: true,
401
400
  data: {
402
401
  state: 'active',
@@ -411,20 +410,19 @@ export function registerAgentSessionRoutes(
411
410
  }
412
411
  }, { summary: 'Get agent session tree or message', tags: ['console', 'agent'] });
413
412
 
414
- route('POST', `${base}/agent/sessions/*`, async (request, response, url, authScope) => {
413
+ route('POST', `${base}/agent/sessions/*`, async (request, { url }) => {
415
414
  const parsed = parseSessionAction(url.pathname, prefix, 'leaf');
416
415
  if (!parsed) {
417
- writeJson(response, 404, { success: false, error: 'Not found' });
418
- return;
416
+ return jsonResponse(404, { success: false, error: 'Not found' });
419
417
  }
420
- if (!requireWriteScope(response, ctx, authScope)) return;
418
+ const denied = requireWriteScope(ctx);
419
+ if (denied) return denied;
421
420
  let body: Record<string, unknown>;
422
421
  try {
423
422
  body = (await readJsonBody(request) ?? {}) as Record<string, unknown>;
424
423
  } catch (error) {
425
424
  if (error instanceof HttpBodyError) {
426
- writeJson(response, error.statusCode, { success: false, error: error.message });
427
- return;
425
+ return jsonResponse(error.statusCode, { success: false, error: error.message });
428
426
  }
429
427
  throw error;
430
428
  }
@@ -435,42 +433,37 @@ export function registerAgentSessionRoutes(
435
433
  if (messageIdRaw != null && messageIdRaw !== '') {
436
434
  const value = Number(messageIdRaw);
437
435
  if (!Number.isFinite(value) || value < 1) {
438
- writeJson(response, 400, { success: false, error: 'messageId 须为正整数' });
439
- return;
436
+ return jsonResponse(400, { success: false, error: 'messageId 须为正整数' });
440
437
  }
441
438
  messageId = value;
442
439
  } else if (indexRaw != null && indexRaw !== '') {
443
440
  const value = Number(indexRaw);
444
441
  if (!Number.isFinite(value) || value < 1) {
445
- writeJson(response, 400, { success: false, error: 'index 须为正整数' });
446
- return;
442
+ return jsonResponse(400, { success: false, error: 'index 须为正整数' });
447
443
  }
448
444
  branchIndex = value;
449
445
  } else {
450
- writeJson(response, 400, { success: false, error: '需要 messageId 或 index 之一' });
451
- return;
446
+ return jsonResponse(400, { success: false, error: '需要 messageId 或 index 之一' });
452
447
  }
453
448
 
454
449
  const lease = acquireSessionTree();
455
450
  if (!lease) {
456
- unavailable(response);
457
- return;
451
+ return unavailable();
458
452
  }
459
453
  const runtime = lease.tree;
460
454
  try {
461
455
  const sessionId = await runtime.resolveActiveSessionId(parsed.sessionKey);
462
456
  if (!sessionId) {
463
- writeJson(response, 404, {
457
+ return jsonResponse(404, {
464
458
  success: false,
465
459
  error: `未找到活跃会话:${parsed.sessionKey}`,
466
460
  });
467
- return;
468
461
  }
469
462
 
470
463
  if (branchIndex !== undefined) {
471
464
  const result = await runtime.jumpToBranchIndex(sessionId, branchIndex);
472
465
  const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
473
- writeJson(response, result.ok ? 200 : 400, {
466
+ return jsonResponse(result.ok ? 200 : 400, {
474
467
  success: result.ok,
475
468
  message: result.message,
476
469
  data: {
@@ -479,12 +472,11 @@ export function registerAgentSessionRoutes(
479
472
  activeLeafMessageId,
480
473
  },
481
474
  });
482
- return;
483
475
  }
484
476
 
485
477
  const ok = await runtime.switchActiveLeaf(sessionId, messageId!);
486
478
  const activeLeafMessageId = await runtime.getActiveLeafMessageId(sessionId);
487
- writeJson(response, ok ? 200 : 400, {
479
+ return jsonResponse(ok ? 200 : 400, {
488
480
  success: ok,
489
481
  message: ok ? `已切换 active leaf 至消息 #${messageId}` : '切换失败',
490
482
  data: {
@@ -576,18 +568,13 @@ export function parsePositiveInt(raw: string | null, fallback: number): number {
576
568
  // helpers
577
569
  // ---------------------------------------------------------------------------
578
570
 
579
- /** 写操作门禁:demo 部署(ctx.fullScope=false)或 demo token 均 403。 */
580
- export function requireWriteScope(
581
- response: ServerResponse,
582
- ctx: ConsoleRestCtx,
583
- authScope: AuthScope,
584
- ): boolean {
585
- if (ctx.fullScope && authScope === 'full') return true;
586
- writeJson(response, 403, {
571
+ /** 写操作门禁:demo 部署(ctx.fullScope=false)返回 403 响应,放行时返回 undefined。 */
572
+ export function requireWriteScope(ctx: ConsoleRestCtx): Response | undefined {
573
+ if (ctx.fullScope) return undefined;
574
+ return jsonResponse(403, {
587
575
  success: false,
588
576
  error: 'Demo scope: 只读访问,写操作被拒绝',
589
577
  });
590
- return false;
591
578
  }
592
579
 
593
580
  export function decodePathParam(raw: string): string {
@@ -608,12 +595,3 @@ export function normalizeBase(value: string): string {
608
595
  if (!value.startsWith('/')) return `/${value}`;
609
596
  return trimTrailingSlashes(value) || '/api';
610
597
  }
611
-
612
- export function writeJson(response: ServerResponse, status: number, body: unknown): void {
613
- const payload = JSON.stringify(body);
614
- response.writeHead(status, {
615
- 'content-type': 'application/json; charset=utf-8',
616
- 'content-length': Buffer.byteLength(payload),
617
- });
618
- response.end(payload);
619
- }