@twinsearth/w2m-dsh-plugin 2.2.6 → 3.1.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.
@@ -0,0 +1,170 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // ssrf-guard — AI / 出站端点的最小 SSRF 防护:
5
+ // * 字面量私网 IP:默认拒绝,除非显式 allowlist
6
+ // * 云元数据地址/主机名:永远拒绝(allowlist 也不能放行)
7
+ // * hostname:配置时只做语法检查;请求前做一次 DNS 解析,
8
+ // 解析到私网/元数据则拒绝(防“公网域名解析到内网”的常见 SSRF)
9
+ //
10
+ // 诚实边界:DNS 预检与 fetch 的真实解析之间仍有 TOCTOU 窗口(DNS rebinding),
11
+ // Node fetch 没有 pinned-lookup 接口;因此这里只做“高置信拦截”,不宣称完备。
12
+ import net from 'node:net'
13
+ import dns from 'node:dns/promises'
14
+
15
+ const METADATA_HOSTS = new Set([
16
+ 'metadata.google.internal',
17
+ 'metadata.goog',
18
+ 'instance-data',
19
+ ])
20
+
21
+ const METADATA_ADDRESSES = new Set([
22
+ '169.254.169.254', // AWS / GCP / Azure IMDS
23
+ '100.100.100.200', // Alibaba Cloud
24
+ '192.0.0.192', // Oracle Cloud
25
+ 'fd00:ec2::254', // AWS IPv6 IMDS
26
+ ])
27
+
28
+ export function normalizeHost(value) {
29
+ let host = String(value ?? '').trim().toLowerCase()
30
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1)
31
+ return host.replace(/\.$/, '')
32
+ }
33
+
34
+ export function isMetadataAddress(value) {
35
+ return METADATA_ADDRESSES.has(normalizeHost(value))
36
+ }
37
+
38
+ export function isMetadataHost(value) {
39
+ return METADATA_HOSTS.has(normalizeHost(value))
40
+ }
41
+
42
+ const inV4Range = (parts, base, bits) => {
43
+ if (!Array.isArray(parts) || parts.length !== 4) return false
44
+ let value = 0
45
+ for (const part of parts) value = (value << 8) | part
46
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0
47
+ return (value & mask) === (base & mask)
48
+ }
49
+
50
+ function ipv4Parts(ip) {
51
+ const parts = ip.split('.').map(Number)
52
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
53
+ return parts
54
+ }
55
+
56
+ function ipv4ToInt(parts) {
57
+ return (((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]) >>> 0
58
+ }
59
+
60
+ export function isPrivateIpv4(ip) {
61
+ const parts = ipv4Parts(ip)
62
+ if (!parts) return false
63
+ const value = ipv4ToInt(parts)
64
+ // RFC1918
65
+ if (inV4Range(parts, ipv4ToInt([10, 0, 0, 0]), 8)) return true
66
+ if (inV4Range(parts, ipv4ToInt([172, 16, 0, 0]), 12)) return true
67
+ if (inV4Range(parts, ipv4ToInt([192, 168, 0, 0]), 16)) return true
68
+ // loopback / link-local / unspecified
69
+ if (inV4Range(parts, ipv4ToInt([127, 0, 0, 0]), 8)) return true
70
+ if (inV4Range(parts, ipv4ToInt([169, 254, 0, 0]), 16)) return true
71
+ if (inV4Range(parts, ipv4ToInt([0, 0, 0, 0]), 8)) return true
72
+ // CGNAT / benchmark / multicast / reserved
73
+ if (inV4Range(parts, ipv4ToInt([100, 64, 0, 0]), 10)) return true
74
+ if (inV4Range(parts, ipv4ToInt([198, 18, 0, 0]), 15)) return true
75
+ if (inV4Range(parts, ipv4ToInt([198, 51, 100, 0]), 24)) return true
76
+ if (inV4Range(parts, ipv4ToInt([203, 0, 113, 0]), 24)) return true
77
+ if (((value & 0xF0000000) >>> 0) === 0xE0000000) return true // multicast
78
+ if (((value & 0xF0000000) >>> 0) === 0xF0000000) return true // reserved
79
+ return false
80
+ }
81
+
82
+ function ipv6Groups(ip) {
83
+ const raw = normalizeHost(ip)
84
+ if (!raw.includes(':')) return null
85
+ const [head, tail] = raw.split('::')
86
+ const parse = (segment) => segment.length === 0 ? [] : segment.split(':')
87
+ let groups = []
88
+ if (raw.includes('::')) groups = [...parse(head), ...Array(8 - parse(head).length - parse(tail ?? '').length).fill('0'), ...parse(tail ?? '')]
89
+ else groups = raw.split(':')
90
+ if (groups.length !== 8) return null
91
+ return groups.map((group) => parseInt(group || '0', 16)).filter((part) => Number.isInteger(part))
92
+ }
93
+
94
+ export function isPrivateIpv6(ip) {
95
+ const raw = normalizeHost(ip)
96
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(raw)
97
+ if (mapped) return isPrivateIpv4(mapped[1])
98
+ const groups = ipv6Groups(raw)
99
+ if (!groups) return false
100
+ if (groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return true // ::1
101
+ if (groups.every((group) => group === 0)) return true // ::
102
+ if ((groups[0] & 0xFE00) === 0xFC00) return true // fc00::/7
103
+ if ((groups[0] & 0xFFC0) === 0xFE80) return true // fe80::/10
104
+ if ((groups[0] & 0xFF00) === 0xFF00) return true // multicast
105
+ // IPv4-mapped ::ffff:a.b.c.d
106
+ if (groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xFFFF) {
107
+ const v4 = `${groups[6] >> 8}.${groups[6] & 0xFF}.${groups[7] >> 8}.${groups[7] & 0xFF}`
108
+ return isPrivateIpv4(v4)
109
+ }
110
+ return false
111
+ }
112
+
113
+ export function isPrivateAddress(ip) {
114
+ const host = normalizeHost(ip)
115
+ const family = net.isIP(host)
116
+ if (family === 4) return isPrivateIpv4(host)
117
+ if (family === 6) return isPrivateIpv6(host)
118
+ return false
119
+ }
120
+
121
+ /** 同步分类:仅处理字面量 IP 与元数据主机名;DNS 主机名留给请求前预检。 */
122
+ export function classifyEndpointHost(hostname, { allowHosts = [] } = {}) {
123
+ const host = normalizeHost(hostname)
124
+ const allowed = new Set((Array.isArray(allowHosts) ? allowHosts : []).map(normalizeHost))
125
+ if (isMetadataHost(host)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata' }
126
+ const family = net.isIP(host)
127
+ if (family === 0) {
128
+ if (host === 'localhost') return { ok: true, kind: 'loopback' }
129
+ return { ok: true, kind: 'hostname' }
130
+ }
131
+ if (isMetadataAddress(host)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata' }
132
+ if (family === 4 && inV4Range(ipv4Parts(host) ?? [], ipv4ToInt([127, 0, 0, 0]), 8)) return { ok: true, kind: 'loopback' }
133
+ if (family === 6) {
134
+ const groups = ipv6Groups(host)
135
+ if (groups && groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return { ok: true, kind: 'loopback' }
136
+ }
137
+ if ((family === 4 && isPrivateIpv4(host)) || (family === 6 && isPrivateIpv6(host))) {
138
+ if (allowed.has(host)) return { ok: true, kind: 'private-allowlisted' }
139
+ return { ok: false, reason: 'private-address-blocked', kind: 'private' }
140
+ }
141
+ return { ok: true, kind: family === 4 ? 'public-ipv4' : 'public-ipv6' }
142
+ }
143
+
144
+ /** 请求前 DNS 预检;解析失败视为 NXDOMAIN(交给 fetch 失败,不视为 SSRF)。 */
145
+ export async function assertEndpointSafe(hostname, { allowHosts = [], resolve = dns.lookup, timeoutMs = 1500 } = {}) {
146
+ const sync = classifyEndpointHost(hostname, { allowHosts })
147
+ if (!sync.ok) return sync
148
+ if (sync.kind !== 'hostname') return sync
149
+ const host = normalizeHost(hostname)
150
+ const allowed = new Set((Array.isArray(allowHosts) ? allowHosts : []).map(normalizeHost))
151
+ // 明确的 metadata 主机名已在 sync 拦截;其余做一次解析。
152
+ let addresses = []
153
+ try {
154
+ const result = await Promise.race([
155
+ resolve(host, { all: true, verbatim: true }),
156
+ new Promise((_, reject) => setTimeout(() => reject(new Error('dns-timeout')), timeoutMs).unref?.()),
157
+ ])
158
+ addresses = Array.isArray(result) ? result : (result?.address ? [result] : [])
159
+ } catch (error) {
160
+ return { ok: true, kind: 'hostname-unresolved', detail: String(error?.message ?? error) }
161
+ }
162
+ for (const entry of addresses) {
163
+ const address = normalizeHost(entry?.address ?? entry)
164
+ if (isMetadataAddress(address)) return { ok: false, reason: 'metadata-address-blocked', kind: 'metadata', address }
165
+ if (isPrivateAddress(address) && !allowed.has(host)) {
166
+ return { ok: false, reason: 'private-address-blocked', kind: 'private', address }
167
+ }
168
+ }
169
+ return { ok: true, kind: 'hostname-public', addresses: addresses.map((entry) => normalizeHost(entry?.address ?? entry)) }
170
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Mount wrapper for W2M's decision center.
3
+ *
4
+ * The ported plugin module (`./index.mjs`) is kept verbatim in shape: it provides the store service
5
+ * and registers the Agent tool. Fang's routes lived in its orchestrator, a component W2M does not
6
+ * have, so this wrapper attaches them to the same Cordis context the plugin was applied to -- one
7
+ * mount point for a profile row, and the subpath export stays a seam rather than a second
8
+ * implementation.
9
+ */
10
+ import { apply as applyCenter, Config, _internals, inject, name } from './index.mjs';
11
+ import { registerDecisionCenterRoutes } from './routes.mjs';
12
+
13
+ export { Config, _internals, inject, name };
14
+
15
+ /**
16
+ * Apply the decision center and mount its HTTP surface.
17
+ *
18
+ * @param {object} ctx Cordis context.
19
+ * @param {object} config plugin configuration (see `Config`).
20
+ * @returns {unknown} whatever the ported apply returns.
21
+ */
22
+ export function apply(ctx, config) {
23
+ const result = applyCenter(ctx, config);
24
+ registerDecisionCenterRoutes(ctx);
25
+ return result;
26
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * HTTP surface of W2M's decision center: `/api/w2m/decisions*`.
3
+ *
4
+ * Ported from the Fang orchestrator's route block (`src/plugins/orchestrator.mjs`, the
5
+ * `api/decisions*` handlers) with three mechanical differences:
6
+ *
7
+ * * the prefix is `/api/w2m/decisions` instead of `${prefix}/api/decisions`;
8
+ * * the handlers answer Web `Request`/`Response` through the host's Connection fetch registry
9
+ * instead of Node's `req`/`res`, which is the only shape a W2M plugin can register;
10
+ * * the two SSE endpoints stream through a `ReadableStream` body, carrying the same event names
11
+ * (`ready`, `ticket`, `ping`) and payloads the Fang helper emits.
12
+ *
13
+ * The decision-center service is read from the plugin's own Cordis context, so the routes never
14
+ * capture an instance: a store that is disposed and re-provided is picked up by the next request.
15
+ */
16
+ import { SERVICE, bus } from './kernel.mjs';
17
+
18
+ /** Route prefix shared with lib/client.js. */
19
+ export const DECISION_ROUTE_PREFIX = '/api/w2m/decisions';
20
+
21
+ const NO_STORE = { 'cache-control': 'no-store' };
22
+ const EVENT_TOPICS = Object.freeze(['fang/decision-created', 'fang/decision-updated']);
23
+ const MAX_EVENT_STREAMS = 20;
24
+
25
+ function json(status, body, headers = {}) {
26
+ return Response.json(body, { status, headers: { ...NO_STORE, ...headers } });
27
+ }
28
+
29
+ function centerOf(ctx) {
30
+ try {
31
+ return ctx.get(SERVICE.decisionCenter);
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ }
36
+
37
+ async function bodyOf(request) {
38
+ try {
39
+ const value = await request.json();
40
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function unavailable() {
47
+ return json(503, { ok: false, error: 'decision-center-unavailable' });
48
+ }
49
+
50
+ function queryOf(request) {
51
+ try {
52
+ return new URL(request.url, 'http://127.0.0.1').searchParams;
53
+ } catch {
54
+ return new URLSearchParams();
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Serve one SSE stream over the fetch API.
60
+ *
61
+ * @param {object} options
62
+ * @param {object} options.center decision-center service (read-only here)
63
+ * @param {(send: (event: string, data: unknown) => void) => (() => void)} options.pump
64
+ * registers the stream's work and returns its disposer.
65
+ * @returns {Response}
66
+ */
67
+ function sseResponse({ center, pump }) {
68
+ const encoder = new TextEncoder();
69
+ let closed = false;
70
+ let dispose = () => {};
71
+ let heartbeat = null;
72
+ const body = new ReadableStream({
73
+ start(controller) {
74
+ const send = (event, data) => {
75
+ if (closed) return false;
76
+ try {
77
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
78
+ return true;
79
+ } catch {
80
+ closed = true;
81
+ return false;
82
+ }
83
+ };
84
+ let initial = null;
85
+ try {
86
+ initial = typeof center?.list === 'function' ? center.list({ includeArchived: false }) : null;
87
+ } catch {
88
+ initial = null;
89
+ }
90
+ send('ready', {
91
+ at: Date.now(),
92
+ rev: Number(initial?.rev) || 0,
93
+ counts: initial?.counts ?? null,
94
+ });
95
+ const extra = pump(send);
96
+ const cleanups = typeof extra === 'function' ? [extra] : [];
97
+ const listeners = EVENT_TOPICS.map((topic) => {
98
+ const handler = (ticket) => {
99
+ send('ticket', {
100
+ at: Date.now(),
101
+ type: topic.endsWith('-created') ? 'created' : 'updated',
102
+ rev: Number(ticket?.rev) || 0,
103
+ ids: ticket?.id ? [String(ticket.id)] : [],
104
+ ticket,
105
+ });
106
+ };
107
+ return bus.on(topic, handler);
108
+ });
109
+ heartbeat = setInterval(() => {
110
+ if (!send('ping', { at: Date.now() })) return;
111
+ }, 15000);
112
+ heartbeat.unref?.();
113
+ dispose = () => {
114
+ if (closed) return;
115
+ closed = true;
116
+ for (const off of listeners) {
117
+ try {
118
+ if (typeof off === 'function') off();
119
+ } catch {
120
+ /* ignore */
121
+ }
122
+ }
123
+ for (const cleanup of cleanups) {
124
+ try {
125
+ cleanup();
126
+ } catch {
127
+ /* ignore */
128
+ }
129
+ }
130
+ if (heartbeat !== null) clearInterval(heartbeat);
131
+ try {
132
+ controller.close();
133
+ } catch {
134
+ /* already closed */
135
+ }
136
+ };
137
+ },
138
+ cancel() {
139
+ try {
140
+ dispose();
141
+ } catch {
142
+ /* ignore */
143
+ }
144
+ },
145
+ });
146
+ return new Response(body, {
147
+ status: 200,
148
+ headers: {
149
+ 'content-type': 'text/event-stream; charset=utf-8',
150
+ 'cache-control': 'no-store, no-cache, must-revalidate',
151
+ 'x-accel-buffering': 'no',
152
+ },
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Mount every decision route on the host's Connection fetch registry.
158
+ *
159
+ * @param {object} ctx Cordis context the decision-center plugin was applied to.
160
+ * @returns {{mounted: boolean, reason: string|null}}
161
+ */
162
+ export function registerDecisionCenterRoutes(ctx) {
163
+ const status = { mounted: false, reason: 'the host has no Connection service' };
164
+ if (typeof ctx?.inject !== 'function') return status;
165
+ ctx.inject(['connection'], (webCtx) => {
166
+ const registry = webCtx?.connection?.fetch;
167
+ if (registry === undefined || typeof registry.register !== 'function') {
168
+ status.reason = 'the connection service carries no fetch registry';
169
+ return;
170
+ }
171
+ let streams = 0;
172
+ const register = (path, methods, fetch) => {
173
+ registry.register({ path, methods, requestBody: 'buffered', fetch });
174
+ };
175
+
176
+ // GET /api/w2m/decisions — pending tickets plus counts.
177
+ register(`${DECISION_ROUTE_PREFIX}`, ['GET'], (request) => {
178
+ const center = centerOf(ctx);
179
+ if (center === undefined || typeof center.list !== 'function') return unavailable();
180
+ const query = queryOf(request);
181
+ const out = center.list({
182
+ includeArchived: query.get('includeArchived') === '1' || query.get('includeArchived') === 'true',
183
+ since: Number(query.get('since')) || 0,
184
+ });
185
+ return json(200, { ok: true, ...out });
186
+ });
187
+
188
+ register(`${DECISION_ROUTE_PREFIX}/quota`, ['GET'], () => {
189
+ const center = centerOf(ctx);
190
+ if (center === undefined || typeof center.quotaStatus !== 'function') return unavailable();
191
+ return json(200, center.quotaStatus());
192
+ });
193
+
194
+ register(`${DECISION_ROUTE_PREFIX}/item`, ['GET'], (request) => {
195
+ const center = centerOf(ctx);
196
+ if (center === undefined || typeof center.get !== 'function') return unavailable();
197
+ const ticket = center.get(String(queryOf(request).get('id') ?? ''));
198
+ return ticket ? json(200, { ok: true, ticket }) : json(404, { ok: false, error: 'not-found' });
199
+ });
200
+
201
+ register(`${DECISION_ROUTE_PREFIX}/forks`, ['GET'], () => {
202
+ const center = centerOf(ctx);
203
+ if (center === undefined || typeof center.forkAggregation !== 'function') return unavailable();
204
+ return json(200, center.forkAggregation());
205
+ });
206
+
207
+ register(`${DECISION_ROUTE_PREFIX}/producers`, ['GET'], () => {
208
+ const center = centerOf(ctx);
209
+ if (center === undefined || typeof center.producerCatalog !== 'function') return unavailable();
210
+ return json(200, center.producerCatalog());
211
+ });
212
+
213
+ // External-producer bridge: ingest only, ticket source marked external:<producer>.
214
+ register(`${DECISION_ROUTE_PREFIX}/ingest`, ['POST'], async (request) => {
215
+ const center = centerOf(ctx);
216
+ if (center === undefined || typeof center.ingest !== 'function') return unavailable();
217
+ const body = await bodyOf(request);
218
+ const out = center.ingest(body ?? {});
219
+ const statusCode = out.ok
220
+ ? 200
221
+ : out.reason === 'title-required' ||
222
+ out.reason === 'options-required' ||
223
+ out.reason === 'gray-is-terminal'
224
+ ? 400
225
+ : 409;
226
+ return json(statusCode, out);
227
+ });
228
+
229
+ // Live push: one authenticated SSE stream per client, polled fallback stays available.
230
+ register(`${DECISION_ROUTE_PREFIX}/events`, ['GET'], () => {
231
+ const center = centerOf(ctx);
232
+ if (center === undefined || typeof center.list !== 'function') return unavailable();
233
+ if (streams >= MAX_EVENT_STREAMS) return json(429, { ok: false, error: 'too-many-event-streams' });
234
+ streams += 1;
235
+ const response = sseResponse({
236
+ center,
237
+ pump: () => () => {
238
+ streams = Math.max(0, streams - 1);
239
+ },
240
+ });
241
+ return response;
242
+ });
243
+
244
+ register(`${DECISION_ROUTE_PREFIX}/resolve`, ['POST'], async (request) => {
245
+ const center = centerOf(ctx);
246
+ if (center === undefined || typeof center.resolve !== 'function') return unavailable();
247
+ const body = await bodyOf(request);
248
+ if (Array.isArray(body?.ids)) {
249
+ if (typeof center.resolveMany !== 'function')
250
+ return json(503, { ok: false, error: 'decision-center-batch-unavailable' });
251
+ return json(
252
+ 200,
253
+ center.resolveMany(body.ids, { option: body?.option, by: body?.by ?? 'w2m-api', note: body?.note }),
254
+ );
255
+ }
256
+ const resolveOptions = { by: body?.by ?? 'w2m-api', note: body?.note };
257
+ if (body?.custom !== undefined) resolveOptions.custom = body.custom;
258
+ const out =
259
+ body?.actionId !== undefined && typeof center.resolveAction === 'function'
260
+ ? center.resolveAction(body?.id, body.actionId, body?.payload, resolveOptions)
261
+ : center.resolve(body?.id, body?.option, resolveOptions);
262
+ const statusCode = out.ok
263
+ ? 200
264
+ : out.reason === 'not-found'
265
+ ? 404
266
+ : out.reason === 'option-required' || out.reason === 'unknown-option'
267
+ ? 400
268
+ : 409;
269
+ return json(statusCode, out);
270
+ });
271
+
272
+ register(`${DECISION_ROUTE_PREFIX}/abandon`, ['POST'], async (request) => {
273
+ const center = centerOf(ctx);
274
+ if (center === undefined || typeof center.abandon !== 'function') return unavailable();
275
+ const body = await bodyOf(request);
276
+ if (Array.isArray(body?.ids)) {
277
+ if (typeof center.abandonMany !== 'function')
278
+ return json(503, { ok: false, error: 'decision-center-batch-unavailable' });
279
+ return json(200, center.abandonMany(body.ids, { by: body?.by ?? 'w2m-api', note: body?.note }));
280
+ }
281
+ const out = center.abandon(body?.id, { by: body?.by ?? 'w2m-api', note: body?.note });
282
+ return json(out.ok ? 200 : out.reason === 'not-found' ? 404 : 409, out);
283
+ });
284
+
285
+ register(`${DECISION_ROUTE_PREFIX}/ai`, ['GET'], () => {
286
+ const center = centerOf(ctx);
287
+ if (center === undefined || typeof center.aiStatus !== 'function') return unavailable();
288
+ return json(200, { ok: true, ...center.aiStatus() });
289
+ });
290
+
291
+ register(`${DECISION_ROUTE_PREFIX}/ai/test`, ['POST'], async (request) => {
292
+ const center = centerOf(ctx);
293
+ if (center === undefined || typeof center.testAi !== 'function') return unavailable();
294
+ const out = await center.testAi((await bodyOf(request)) ?? {});
295
+ return json(out.ok ? 200 : out.status === 429 ? 429 : 400, out);
296
+ });
297
+
298
+ register(`${DECISION_ROUTE_PREFIX}/ai/config`, ['POST'], async (request) => {
299
+ const center = centerOf(ctx);
300
+ if (center === undefined || typeof center.configureAi !== 'function') return unavailable();
301
+ const out = center.configureAi((await bodyOf(request)) ?? {});
302
+ return json(out.ok ? 200 : 400, out);
303
+ });
304
+
305
+ register(`${DECISION_ROUTE_PREFIX}/ai/advise/stream`, ['POST'], async (request) => {
306
+ const center = centerOf(ctx);
307
+ if (center === undefined || typeof center.advise !== 'function') return unavailable();
308
+ const body = await bodyOf(request);
309
+ const id = String(body?.id ?? '');
310
+ return sseResponse({
311
+ center,
312
+ pump: (send) => {
313
+ let cancelled = false;
314
+ send('start', { id });
315
+ void (async () => {
316
+ try {
317
+ const out =
318
+ typeof center.adviseStream === 'function'
319
+ ? await center.adviseStream(id, (delta, text) => {
320
+ if (!cancelled) send('delta', { delta, text });
321
+ })
322
+ : await center.advise(id);
323
+ if (!cancelled)
324
+ send(out.ok ? 'done' : 'error', out.ok ? { advice: out.advice, ticket: out.ticket } : out);
325
+ } catch (error) {
326
+ if (!cancelled) send('error', { ok: false, error: String(error?.message ?? error) });
327
+ }
328
+ })();
329
+ return () => {
330
+ cancelled = true;
331
+ };
332
+ },
333
+ });
334
+ });
335
+
336
+ register(`${DECISION_ROUTE_PREFIX}/ai/advise`, ['POST'], async (request) => {
337
+ const center = centerOf(ctx);
338
+ if (center === undefined || typeof center.advise !== 'function') return unavailable();
339
+ const body = await bodyOf(request);
340
+ const out = await center.advise(body?.id);
341
+ const limited =
342
+ out?.reason === 'ai-rate-limited' ||
343
+ out?.reason === 'ai-circuit-open' ||
344
+ out?.reason === 'ai-daily-quota-exceeded';
345
+ const headers =
346
+ limited && Number.isFinite(out?.retryAfterMs) && out.retryAfterMs > 0
347
+ ? { 'retry-after': String(Math.ceil(out.retryAfterMs / 1000)) }
348
+ : {};
349
+ return json(out.ok ? 200 : out.reason === 'not-found' ? 404 : limited ? 429 : 409, out, headers);
350
+ });
351
+
352
+ status.mounted = true;
353
+ status.reason = null;
354
+ });
355
+ return status;
356
+ }