@evomap/evolver-adapter-public 2.0.0-beta.8 → 2.0.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.
- package/dist/antiAbuseTelemetry.js +2 -1
- package/dist/auth/credentialStore.d.ts +87 -3
- package/dist/auth/credentialStore.js +1065 -10
- package/dist/auth/oauthDeviceToken.d.ts +9 -6
- package/dist/auth/oauthDeviceToken.js +71 -18
- package/dist/auth/oauthHttpTransport.d.ts +4 -0
- package/dist/auth/oauthHttpTransport.js +66 -15
- package/dist/auth/windowsPowerShell.d.ts +3 -0
- package/dist/auth/windowsPowerShell.js +91 -0
- package/dist/hubCapability.d.ts +16 -4
- package/dist/hubCapability.js +322 -45
- package/dist/hubFetch.d.ts +44 -11
- package/dist/hubFetch.js +329 -76
- package/dist/hubReuse.d.ts +40 -0
- package/dist/hubReuse.js +303 -32
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/learningPacketFeedback.d.ts +68 -0
- package/dist/learningPacketFeedback.js +104 -0
- package/dist/learningPacketSink.d.ts +40 -0
- package/dist/learningPacketSink.js +153 -0
- package/dist/wireMap.d.ts +3 -1
- package/dist/wireMap.js +29 -3
- package/package.json +6 -3
package/dist/hubFetch.js
CHANGED
|
@@ -5,6 +5,29 @@ export const HUB_ERROR_TEXT_MAX_BYTES = 8 * 1024;
|
|
|
5
5
|
export const HUB_JSON_TEXT_MAX_BYTES = 4 * 1024 * 1024;
|
|
6
6
|
export const HUB_UNREACHABLE_BACKOFF_BASE_MS = 60_000;
|
|
7
7
|
export const HUB_UNREACHABLE_BACKOFF_MAX_MS = 10 * 60_000;
|
|
8
|
+
export const HUB_GENERAL_TIMEOUT_MS = 15_000;
|
|
9
|
+
export const HUB_SEARCH_TIMEOUT_MS = 8_000;
|
|
10
|
+
export const HUB_HEARTBEAT_TIMEOUT_MS = 10_000;
|
|
11
|
+
export const HUB_EVENT_POLL_TIMEOUT_MS = 60_000;
|
|
12
|
+
export const HUB_HELLO_TIMEOUT_MS = 15_000;
|
|
13
|
+
const HUB_OPERATION_TIMEOUT_KEYS = {
|
|
14
|
+
general: 'generalMs',
|
|
15
|
+
search: 'searchMs',
|
|
16
|
+
heartbeat: 'heartbeatMs',
|
|
17
|
+
poll: 'pollMs',
|
|
18
|
+
hello: 'helloMs',
|
|
19
|
+
};
|
|
20
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 20_000;
|
|
21
|
+
const DEFAULT_DEADLINE_SCHEDULER = {
|
|
22
|
+
set(callback, delayMs) {
|
|
23
|
+
const timer = setTimeout(callback, delayMs);
|
|
24
|
+
timer.unref?.();
|
|
25
|
+
return timer;
|
|
26
|
+
},
|
|
27
|
+
clear(handle) {
|
|
28
|
+
clearTimeout(handle);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
8
31
|
export class AuthError extends Error {
|
|
9
32
|
status;
|
|
10
33
|
body;
|
|
@@ -21,10 +44,12 @@ export class AuthError extends Error {
|
|
|
21
44
|
export class HubClientError extends Error {
|
|
22
45
|
status;
|
|
23
46
|
body;
|
|
24
|
-
|
|
47
|
+
retryAfterMs;
|
|
48
|
+
constructor(status, body, retryAfterMs) {
|
|
25
49
|
super(`hub ${status}`);
|
|
26
50
|
this.status = status;
|
|
27
51
|
this.body = body;
|
|
52
|
+
this.retryAfterMs = retryAfterMs;
|
|
28
53
|
this.name = 'HubClientError';
|
|
29
54
|
}
|
|
30
55
|
}
|
|
@@ -40,6 +65,33 @@ export class HubUnreachableError extends Error {
|
|
|
40
65
|
return this.details.retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS;
|
|
41
66
|
}
|
|
42
67
|
}
|
|
68
|
+
const PROTECTED_REQUEST_HEADERS = new Set([
|
|
69
|
+
'authorization',
|
|
70
|
+
'content-type',
|
|
71
|
+
'x-evomap-node-secret-version',
|
|
72
|
+
'x-evomap-signature',
|
|
73
|
+
'x-node-secret',
|
|
74
|
+
]);
|
|
75
|
+
function mergeRequestHeaders(requestHeaders, signedHeaders) {
|
|
76
|
+
const signedNames = new Set(Object.keys(signedHeaders ?? {}).map((name) => name.toLowerCase()));
|
|
77
|
+
const headers = {};
|
|
78
|
+
for (const [name, value] of Object.entries(requestHeaders ?? {})) {
|
|
79
|
+
const normalized = name.toLowerCase();
|
|
80
|
+
if (PROTECTED_REQUEST_HEADERS.has(normalized) || signedNames.has(normalized))
|
|
81
|
+
continue;
|
|
82
|
+
if (normalized === 'idempotency-key') {
|
|
83
|
+
const trimmed = value.trim();
|
|
84
|
+
if (!trimmed)
|
|
85
|
+
throw new Error('idempotency-key must be non-empty');
|
|
86
|
+
headers[normalized] = trimmed;
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
headers[normalized] = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
headers['content-type'] = 'application/json';
|
|
93
|
+
return { ...headers, ...signedHeaders };
|
|
94
|
+
}
|
|
43
95
|
/**
|
|
44
96
|
* 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证: POST 通常注入 body; GET 与 strict hello envelope
|
|
45
97
|
* 走 **Authorization: Bearer <node_secret>** 头(hub 只从 header/body 读 node_secret, 绝不从 query — #8);
|
|
@@ -49,69 +101,206 @@ export class HubUnreachableError extends Error {
|
|
|
49
101
|
*/
|
|
50
102
|
export class HubFetch {
|
|
51
103
|
deps;
|
|
104
|
+
operationTimeouts;
|
|
105
|
+
deadlineScheduler;
|
|
52
106
|
constructor(deps) {
|
|
53
107
|
this.deps = deps;
|
|
108
|
+
this.operationTimeouts = resolveHubOperationTimeouts(deps.env ?? process.env, deps.operationTimeouts);
|
|
109
|
+
this.deadlineScheduler = deps.deadlineScheduler ?? DEFAULT_DEADLINE_SCHEDULER;
|
|
54
110
|
}
|
|
55
|
-
async call(method, path, bodyObj, query) {
|
|
111
|
+
async call(method, path, bodyObj, query, requestHeaders) {
|
|
112
|
+
const operation = hubOperationForRequest(path, bodyObj);
|
|
56
113
|
const draft = bodyObj !== undefined ? JSON.stringify(bodyObj) : '';
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
assertHubUrlSecure(url); // request-level scheme guard (defense in depth): even a misconfigured injected fetchFn cannot egress in plaintext
|
|
62
|
-
let body;
|
|
63
|
-
const headers = { 'content-type': 'application/json', ...signed.headers };
|
|
64
|
-
if (method === 'GET') {
|
|
65
|
-
const qs = new URLSearchParams();
|
|
66
|
-
if (sender)
|
|
67
|
-
qs.set('sender_id', sender); // identifier, not a credential — query is fine
|
|
68
|
-
if (query)
|
|
69
|
-
for (const [k, v] of Object.entries(query))
|
|
70
|
-
if (v !== undefined)
|
|
71
|
-
qs.set(k, String(v)); // non-credential GET params (e.g. semantic-search q)
|
|
72
|
-
// #8: credentials must NOT go in the query (leaks to access logs / proxies even over https).
|
|
73
|
-
// node_secret travels via Authorization: Bearer; the hub reads it there, never from the query.
|
|
74
|
-
const nodeSecret = creds['node_secret'];
|
|
75
|
-
if (nodeSecret !== undefined && headers['authorization'] === undefined)
|
|
76
|
-
headers['authorization'] = `Bearer ${String(nodeSecret)}`;
|
|
77
|
-
const q = qs.toString();
|
|
78
|
-
if (q)
|
|
79
|
-
url += `?${q}`;
|
|
114
|
+
const authDeadline = createHubDeadline(this.deadlineScheduler, method, path, operation, this.deps.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS);
|
|
115
|
+
let signed;
|
|
116
|
+
try {
|
|
117
|
+
signed = await awaitWithAbort(this.deps.auth.authenticate({ method, path, ...(draft ? { body: draft } : {}), signal: authDeadline.signal }), authDeadline.signal);
|
|
80
118
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
if (path === '/a2a/mailbox/outbound' && sender) {
|
|
90
|
-
const qs = new URLSearchParams({ sender_id: sender });
|
|
91
|
-
url += `?${qs.toString()}`;
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (isAuthTransportTimeout(error)) {
|
|
121
|
+
throw new HubUnreachableError('hub authentication timed out', {
|
|
122
|
+
context: `${method} ${path}`,
|
|
123
|
+
retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
124
|
+
operation,
|
|
125
|
+
});
|
|
92
126
|
}
|
|
93
|
-
|
|
127
|
+
throw error;
|
|
94
128
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
res = await this.deps.fetchFn(url, { method, headers, ...(body ? { body } : {}) });
|
|
129
|
+
finally {
|
|
130
|
+
authDeadline.dispose();
|
|
98
131
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
132
|
+
const timeoutMs = this.operationTimeouts[HUB_OPERATION_TIMEOUT_KEYS[operation]];
|
|
133
|
+
const deadline = createHubDeadline(this.deadlineScheduler, method, path, operation, timeoutMs);
|
|
134
|
+
try {
|
|
135
|
+
const sender = this.deps.senderId();
|
|
136
|
+
const creds = signed.bodyFields ?? {};
|
|
137
|
+
let url = `${this.deps.baseUrl}${path}`;
|
|
138
|
+
assertHubUrlSecure(url); // request-level scheme guard (defense in depth): even a misconfigured injected fetchFn cannot egress in plaintext
|
|
139
|
+
let body;
|
|
140
|
+
const headers = mergeRequestHeaders(requestHeaders, signed.headers);
|
|
141
|
+
if (method === 'GET') {
|
|
142
|
+
const qs = new URLSearchParams();
|
|
143
|
+
if (sender)
|
|
144
|
+
qs.set('sender_id', sender); // identifier, not a credential — query is fine
|
|
145
|
+
if (query)
|
|
146
|
+
for (const [k, v] of Object.entries(query))
|
|
147
|
+
if (v !== undefined)
|
|
148
|
+
qs.set(k, String(v)); // non-credential GET params (e.g. semantic-search q)
|
|
149
|
+
// #8: credentials must NOT go in the query (leaks to access logs / proxies even over https).
|
|
150
|
+
// node_secret travels via Authorization: Bearer; the hub reads it there, never from the query.
|
|
151
|
+
const nodeSecret = creds['node_secret'];
|
|
152
|
+
if (nodeSecret !== undefined && headers['authorization'] === undefined)
|
|
153
|
+
headers['authorization'] = `Bearer ${String(nodeSecret)}`;
|
|
154
|
+
const q = qs.toString();
|
|
155
|
+
if (q)
|
|
156
|
+
url += `?${q}`;
|
|
102
157
|
}
|
|
103
|
-
|
|
158
|
+
else {
|
|
159
|
+
const postCreds = { ...creds };
|
|
160
|
+
const nodeSecret = postCreds['node_secret'];
|
|
161
|
+
if ((path === '/a2a/hello' || path === '/a2a/mailbox/outbound') && nodeSecret !== undefined) {
|
|
162
|
+
if (headers['authorization'] === undefined)
|
|
163
|
+
headers['authorization'] = `Bearer ${String(nodeSecret)}`;
|
|
164
|
+
delete postCreds['node_secret'];
|
|
165
|
+
}
|
|
166
|
+
if (path === '/a2a/mailbox/outbound' && sender) {
|
|
167
|
+
const qs = new URLSearchParams({ sender_id: sender });
|
|
168
|
+
url += `?${qs.toString()}`;
|
|
169
|
+
}
|
|
170
|
+
body = JSON.stringify({ ...(sender ? { sender_id: sender } : {}), ...postCreds, ...(bodyObj ?? {}) });
|
|
171
|
+
}
|
|
172
|
+
let res;
|
|
173
|
+
try {
|
|
174
|
+
res = await awaitWithAbort(this.deps.fetchFn(url, {
|
|
175
|
+
method,
|
|
176
|
+
headers,
|
|
177
|
+
...(body ? { body } : {}),
|
|
178
|
+
signal: deadline.signal,
|
|
179
|
+
redirect: 'manual',
|
|
180
|
+
}), deadline.signal);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (deadline.signal.aborted)
|
|
184
|
+
throw deadline.error;
|
|
185
|
+
if (isHubUnreachableError(err)) {
|
|
186
|
+
throw new HubUnreachableError(`${method} ${path} failed before a Hub API response arrived`, { context: `${method} ${path}`, retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS });
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
if (deadline.signal.aborted)
|
|
191
|
+
throw deadline.error;
|
|
192
|
+
const retryAfterMs = parseRetryAfterMs(headerValue(res.headers, 'retry-after'), this.deps.now?.() ?? Date.now());
|
|
193
|
+
if (res.status >= 300 && res.status < 400) {
|
|
194
|
+
await drainHubResponse(res, { signal: deadline.signal });
|
|
195
|
+
if (deadline.signal.aborted)
|
|
196
|
+
throw deadline.error;
|
|
197
|
+
throw new HubUnreachableError(`${method} ${path} refused an unexpected Hub redirect`, { status: res.status, context: `${method} ${path}`, retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS });
|
|
198
|
+
}
|
|
199
|
+
const parsed = await readHubResponseJsonForClassification(res, deadline.signal);
|
|
200
|
+
if (deadline.signal.aborted)
|
|
201
|
+
throw deadline.error;
|
|
202
|
+
throwIfParsedHubUnreachableResponse(res, parsed, `${method} ${path}`, retryAfterMs);
|
|
203
|
+
if (res.status === 401 || res.status === 403)
|
|
204
|
+
throw new AuthError(res.status, parsed.body);
|
|
205
|
+
if (res.status >= 400 && res.status < 500) {
|
|
206
|
+
throw new HubClientError(res.status, parsed.ok ? parsed.body : {}, retryAfterMs);
|
|
207
|
+
}
|
|
208
|
+
if (res.status >= 500)
|
|
209
|
+
throw new Error(`hub ${res.status}`);
|
|
210
|
+
return parsed.body;
|
|
104
211
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
212
|
+
finally {
|
|
213
|
+
deadline.dispose();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function positiveTimeoutMs(value, fallback) {
|
|
218
|
+
if (value === undefined || value === '')
|
|
219
|
+
return fallback;
|
|
220
|
+
const raw = String(value);
|
|
221
|
+
if (!/^\d+$/.test(raw))
|
|
222
|
+
return fallback;
|
|
223
|
+
const parsed = Number(raw);
|
|
224
|
+
return Number.isFinite(parsed) && parsed > 0 && parsed < 2 ** 31 ? parsed : fallback;
|
|
225
|
+
}
|
|
226
|
+
export function resolveHubOperationTimeouts(env = process.env, overrides = {}) {
|
|
227
|
+
const fromEnv = {
|
|
228
|
+
generalMs: positiveTimeoutMs(env['EVOLVER_HTTP_TRANSPORT_TIMEOUT_MS'], HUB_GENERAL_TIMEOUT_MS),
|
|
229
|
+
searchMs: positiveTimeoutMs(env['EVOLVER_HUB_SEARCH_TIMEOUT_MS'], HUB_SEARCH_TIMEOUT_MS),
|
|
230
|
+
heartbeatMs: positiveTimeoutMs(env['EVOLVER_HEARTBEAT_TIMEOUT_MS'], HUB_HEARTBEAT_TIMEOUT_MS),
|
|
231
|
+
pollMs: positiveTimeoutMs(env['EVOLVER_EVENT_POLL_TIMEOUT_MS'], HUB_EVENT_POLL_TIMEOUT_MS),
|
|
232
|
+
helloMs: positiveTimeoutMs(env['EVOLVER_HELLO_TIMEOUT_MS'], HUB_HELLO_TIMEOUT_MS),
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
generalMs: positiveTimeoutMs(overrides.generalMs, fromEnv.generalMs),
|
|
236
|
+
searchMs: positiveTimeoutMs(overrides.searchMs, fromEnv.searchMs),
|
|
237
|
+
heartbeatMs: positiveTimeoutMs(overrides.heartbeatMs, fromEnv.heartbeatMs),
|
|
238
|
+
pollMs: positiveTimeoutMs(overrides.pollMs, fromEnv.pollMs),
|
|
239
|
+
helloMs: positiveTimeoutMs(overrides.helloMs, fromEnv.helloMs),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function hubOperationForRequest(path, bodyObj) {
|
|
243
|
+
if (path === '/a2a/fetch') {
|
|
244
|
+
const payload = bodyObj?.['payload'];
|
|
245
|
+
if (payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
|
246
|
+
&& payload['search_only'] === true)
|
|
247
|
+
return 'search';
|
|
114
248
|
}
|
|
249
|
+
if (path === '/a2a/assets/semantic-search' || path === '/a2a/directory/search')
|
|
250
|
+
return 'search';
|
|
251
|
+
if (path === '/a2a/heartbeat')
|
|
252
|
+
return 'heartbeat';
|
|
253
|
+
if (path === '/a2a/events/poll')
|
|
254
|
+
return 'poll';
|
|
255
|
+
if (path === '/a2a/hello')
|
|
256
|
+
return 'hello';
|
|
257
|
+
return 'general';
|
|
258
|
+
}
|
|
259
|
+
function createHubDeadline(scheduler, method, path, operation, timeoutMs) {
|
|
260
|
+
const controller = new AbortController();
|
|
261
|
+
const error = new HubUnreachableError(`${method} ${path} timed out after ${timeoutMs}ms`, {
|
|
262
|
+
context: `${method} ${path}`,
|
|
263
|
+
retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
264
|
+
operation,
|
|
265
|
+
timeoutMs,
|
|
266
|
+
});
|
|
267
|
+
const handle = scheduler.set(() => controller.abort(error), timeoutMs);
|
|
268
|
+
return {
|
|
269
|
+
signal: controller.signal,
|
|
270
|
+
error,
|
|
271
|
+
dispose: () => scheduler.clear(handle),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function abortReason(signal) {
|
|
275
|
+
if (signal.reason instanceof Error)
|
|
276
|
+
return signal.reason;
|
|
277
|
+
const error = new Error('Hub request aborted');
|
|
278
|
+
error.name = 'AbortError';
|
|
279
|
+
return error;
|
|
280
|
+
}
|
|
281
|
+
async function awaitWithAbort(promise, signal) {
|
|
282
|
+
const pending = Promise.resolve(promise);
|
|
283
|
+
if (!signal)
|
|
284
|
+
return await pending;
|
|
285
|
+
if (signal.aborted) {
|
|
286
|
+
void pending.catch(() => { });
|
|
287
|
+
throw abortReason(signal);
|
|
288
|
+
}
|
|
289
|
+
return await new Promise((resolve, reject) => {
|
|
290
|
+
const onAbort = () => {
|
|
291
|
+
cleanup();
|
|
292
|
+
reject(abortReason(signal));
|
|
293
|
+
};
|
|
294
|
+
const cleanup = () => signal.removeEventListener('abort', onAbort);
|
|
295
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
296
|
+
void pending.then((value) => {
|
|
297
|
+
cleanup();
|
|
298
|
+
resolve(value);
|
|
299
|
+
}, (error) => {
|
|
300
|
+
cleanup();
|
|
301
|
+
reject(error);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
115
304
|
}
|
|
116
305
|
function hubErrorCode(body) {
|
|
117
306
|
const record = body && typeof body === 'object' && !Array.isArray(body) ? body : undefined;
|
|
@@ -149,6 +338,19 @@ function headerValue(headers, name) {
|
|
|
149
338
|
return '';
|
|
150
339
|
}
|
|
151
340
|
}
|
|
341
|
+
function parseRetryAfterMs(value, now) {
|
|
342
|
+
const raw = value.trim();
|
|
343
|
+
if (!raw || !Number.isFinite(now))
|
|
344
|
+
return undefined;
|
|
345
|
+
if (/^\d+$/.test(raw)) {
|
|
346
|
+
const milliseconds = Number(raw) * 1_000;
|
|
347
|
+
return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
|
|
348
|
+
}
|
|
349
|
+
if (/^[+-]?\d+(?:\.\d+)?$/.test(raw))
|
|
350
|
+
return undefined;
|
|
351
|
+
const retryAt = Date.parse(raw);
|
|
352
|
+
return Number.isFinite(retryAt) ? Math.max(0, retryAt - now) : undefined;
|
|
353
|
+
}
|
|
152
354
|
export function hubResponseContentType(res) {
|
|
153
355
|
return headerValue(res?.headers, 'content-type').toLowerCase();
|
|
154
356
|
}
|
|
@@ -176,6 +378,10 @@ const NETWORK_DISRUPTION_CODES = new Set([
|
|
|
176
378
|
'UND_ERR_HEADERS_TIMEOUT',
|
|
177
379
|
'UND_ERR_BODY_TIMEOUT',
|
|
178
380
|
]);
|
|
381
|
+
function isAuthTransportTimeout(error) {
|
|
382
|
+
return error instanceof Error
|
|
383
|
+
&& (error.message === 'oauth_refresh_timeout' || error.message === 'device_flow_timeout');
|
|
384
|
+
}
|
|
179
385
|
export function isHubUnreachableError(err) {
|
|
180
386
|
const e = err;
|
|
181
387
|
if (!e)
|
|
@@ -207,11 +413,11 @@ function toBytes(value) {
|
|
|
207
413
|
return Buffer.from(value, 'utf8');
|
|
208
414
|
return Buffer.from(String(value ?? ''), 'utf8');
|
|
209
415
|
}
|
|
210
|
-
export async function drainHubResponse(res) {
|
|
416
|
+
export async function drainHubResponse(res, opts = {}) {
|
|
211
417
|
const body = res?.body;
|
|
212
418
|
try {
|
|
213
419
|
if (body && typeof body.cancel === 'function') {
|
|
214
|
-
await body.cancel();
|
|
420
|
+
await awaitWithAbort(Promise.resolve(body.cancel()), opts.signal);
|
|
215
421
|
}
|
|
216
422
|
}
|
|
217
423
|
catch {
|
|
@@ -228,7 +434,7 @@ export async function readHubResponseText(res, opts = {}) {
|
|
|
228
434
|
let truncated = false;
|
|
229
435
|
try {
|
|
230
436
|
for (;;) {
|
|
231
|
-
const part = await reader.read();
|
|
437
|
+
const part = await awaitWithAbort(reader.read(), opts.signal);
|
|
232
438
|
if (part.done)
|
|
233
439
|
break;
|
|
234
440
|
const bytes = toBytes(part.value);
|
|
@@ -239,7 +445,7 @@ export async function readHubResponseText(res, opts = {}) {
|
|
|
239
445
|
total += remaining;
|
|
240
446
|
}
|
|
241
447
|
truncated = true;
|
|
242
|
-
await reader.
|
|
448
|
+
await cancelReader(reader, opts.signal);
|
|
243
449
|
break;
|
|
244
450
|
}
|
|
245
451
|
chunks.push(bytes);
|
|
@@ -247,7 +453,7 @@ export async function readHubResponseText(res, opts = {}) {
|
|
|
247
453
|
}
|
|
248
454
|
}
|
|
249
455
|
catch (err) {
|
|
250
|
-
await reader.
|
|
456
|
+
await cancelReader(reader, opts.signal);
|
|
251
457
|
throw err;
|
|
252
458
|
}
|
|
253
459
|
finally {
|
|
@@ -260,14 +466,27 @@ export async function readHubResponseText(res, opts = {}) {
|
|
|
260
466
|
return '';
|
|
261
467
|
throw new Error('hub response body stream missing');
|
|
262
468
|
}
|
|
469
|
+
async function cancelReader(reader, signal) {
|
|
470
|
+
try {
|
|
471
|
+
if (reader.cancel)
|
|
472
|
+
await awaitWithAbort(Promise.resolve(reader.cancel()), signal);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
// Cancellation is best-effort; preserve the read/timeout error.
|
|
476
|
+
}
|
|
477
|
+
}
|
|
263
478
|
export async function readHubResponseJson(res, opts = {}) {
|
|
264
|
-
const text = await readHubResponseText(res, {
|
|
479
|
+
const text = await readHubResponseText(res, {
|
|
480
|
+
maxBytes: opts.maxBytes ?? HUB_JSON_TEXT_MAX_BYTES,
|
|
481
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
482
|
+
});
|
|
265
483
|
return JSON.parse(text);
|
|
266
484
|
}
|
|
267
485
|
export async function throwIfHubUnreachableResponse(res, context = 'hub') {
|
|
486
|
+
const retryAfterMs = parseRetryAfterMs(headerValue(res.headers, 'retry-after'), Date.now());
|
|
268
487
|
if (!isHubUnreachableResponse(res)) {
|
|
269
488
|
const parsed = await readHubResponseJsonForClassification(res);
|
|
270
|
-
throwIfParsedHubUnreachableResponse(res, parsed, context);
|
|
489
|
+
throwIfParsedHubUnreachableResponse(res, parsed, context, retryAfterMs);
|
|
271
490
|
return;
|
|
272
491
|
}
|
|
273
492
|
const status = Number(res.status) || undefined;
|
|
@@ -278,22 +497,30 @@ export async function throwIfHubUnreachableResponse(res, context = 'hub') {
|
|
|
278
497
|
catch {
|
|
279
498
|
// Best-effort pool hygiene only.
|
|
280
499
|
}
|
|
281
|
-
throw new HubUnreachableError(`${context} returned a non-API Hub response (${status ?? 'unknown status'}, ${contentType})`, {
|
|
500
|
+
throw new HubUnreachableError(`${context} returned a non-API Hub response (${status ?? 'unknown status'}, ${contentType})`, {
|
|
501
|
+
...(status !== undefined ? { status } : {}),
|
|
502
|
+
contentType,
|
|
503
|
+
context,
|
|
504
|
+
retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
505
|
+
});
|
|
282
506
|
}
|
|
283
|
-
async function readHubResponseJsonForClassification(res) {
|
|
507
|
+
async function readHubResponseJsonForClassification(res, signal) {
|
|
284
508
|
if (isHubUnreachableResponse(res)) {
|
|
285
|
-
await drainHubResponse(res);
|
|
509
|
+
await drainHubResponse(res, { ...(signal ? { signal } : {}) });
|
|
286
510
|
return { ok: false, reason: 'non_api_content_type' };
|
|
287
511
|
}
|
|
288
512
|
try {
|
|
289
|
-
const text = await readHubResponseText(res, {
|
|
513
|
+
const text = await readHubResponseText(res, {
|
|
514
|
+
maxBytes: HUB_JSON_TEXT_MAX_BYTES,
|
|
515
|
+
...(signal ? { signal } : {}),
|
|
516
|
+
});
|
|
290
517
|
return { ok: true, body: JSON.parse(text) };
|
|
291
518
|
}
|
|
292
519
|
catch (err) {
|
|
293
520
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
294
521
|
}
|
|
295
522
|
}
|
|
296
|
-
function throwIfParsedHubUnreachableResponse(res, parsed, context) {
|
|
523
|
+
function throwIfParsedHubUnreachableResponse(res, parsed, context, retryAfterMs) {
|
|
297
524
|
const status = Number(res.status) || undefined;
|
|
298
525
|
const contentType = hubResponseContentType(res);
|
|
299
526
|
if (!isHubApiResponse(res) || !parsed.ok) {
|
|
@@ -301,7 +528,7 @@ function throwIfParsedHubUnreachableResponse(res, parsed, context) {
|
|
|
301
528
|
...(status !== undefined ? { status } : {}),
|
|
302
529
|
contentType: contentType || 'unknown content-type',
|
|
303
530
|
context,
|
|
304
|
-
retryAfterMs: HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
531
|
+
retryAfterMs: retryAfterMs ?? HUB_UNREACHABLE_BACKOFF_BASE_MS,
|
|
305
532
|
});
|
|
306
533
|
}
|
|
307
534
|
}
|
|
@@ -337,6 +564,7 @@ export function assertHubUrlSecure(url, env = process.env) {
|
|
|
337
564
|
}
|
|
338
565
|
export const HUB_CONNECT_TIMEOUT_MS = 10_000;
|
|
339
566
|
export const HUB_IPV4FIRST_PRIMARY_CONNECT_TIMEOUT_MS = 2_500;
|
|
567
|
+
export const HUB_TCP_KEEPALIVE_IDLE_MS = 15_000;
|
|
340
568
|
export function resolveHubIpFamily(env = process.env) {
|
|
341
569
|
const raw = String(env['EVOMAP_HUB_IP_FAMILY'] ?? 'ipv4first').trim().toLowerCase();
|
|
342
570
|
if (raw === 'ipv4' || raw === 'v4' || raw === '4' || raw === 'ipv4first' || raw === 'ipv4-first')
|
|
@@ -401,24 +629,41 @@ function errorCode(err) {
|
|
|
401
629
|
function shouldFallbackFromIpv4(err, hubIpFamily) {
|
|
402
630
|
return hubIpFamily === 'ipv4first' && IPV4_FALLBACK_CODES.has(errorCode(err) ?? '');
|
|
403
631
|
}
|
|
632
|
+
function configureHubSocket(socket) {
|
|
633
|
+
if (socket === null || typeof socket !== 'object')
|
|
634
|
+
return;
|
|
635
|
+
const setKeepAlive = socket.setKeepAlive;
|
|
636
|
+
if (typeof setKeepAlive !== 'function')
|
|
637
|
+
return;
|
|
638
|
+
try {
|
|
639
|
+
setKeepAlive.call(socket, true, HUB_TCP_KEEPALIVE_IDLE_MS);
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// Kernel/socket support varies. A keepalive tuning failure must never turn a valid TLS connection into an outage.
|
|
643
|
+
}
|
|
644
|
+
}
|
|
404
645
|
function makeHubConnector(config) {
|
|
405
646
|
const primaryConnect = buildConnector(config.primaryConnectOpts);
|
|
406
647
|
const fallbackConnect = config.fallbackConnectOpts ? buildConnector(config.fallbackConnectOpts) : null;
|
|
407
648
|
const connector = (opts, cb) => {
|
|
408
|
-
|
|
409
|
-
if (err && fallbackConnect && shouldFallbackFromIpv4(err, config.hubIpFamily)) {
|
|
410
|
-
fallbackConnect(opts, cb);
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
649
|
+
const finish = (err, socket) => {
|
|
413
650
|
if (err) {
|
|
414
651
|
cb(err, null);
|
|
415
652
|
return;
|
|
416
653
|
}
|
|
417
|
-
if (socket) {
|
|
418
|
-
cb(
|
|
654
|
+
if (!socket) {
|
|
655
|
+
cb(new Error('[hubFetch] undici connector returned no socket'), null);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
configureHubSocket(socket);
|
|
659
|
+
cb(null, socket);
|
|
660
|
+
};
|
|
661
|
+
primaryConnect(opts, (err, socket) => {
|
|
662
|
+
if (err && fallbackConnect && shouldFallbackFromIpv4(err, config.hubIpFamily)) {
|
|
663
|
+
fallbackConnect(opts, finish);
|
|
419
664
|
return;
|
|
420
665
|
}
|
|
421
|
-
|
|
666
|
+
finish(err, socket);
|
|
422
667
|
});
|
|
423
668
|
};
|
|
424
669
|
const marked = connector;
|
|
@@ -429,7 +674,12 @@ const HUB_FETCH_CONFIG = makeHubFetchTransportConfig(process.env);
|
|
|
429
674
|
// Singleton TLS-enforcing dispatcher: overrides a global NODE_TLS_REJECT_UNAUTHORIZED=0. The Agent and
|
|
430
675
|
// fetch MUST come from the same undici package (mixing an npm-undici Agent with Node's built-in global.fetch
|
|
431
676
|
// throws UND_ERR_INVALID_ARG). The connector applies TLS verification plus the selected Hub IP-family policy.
|
|
432
|
-
const STRICT_TLS_AGENT = new Agent({
|
|
677
|
+
const STRICT_TLS_AGENT = new Agent({
|
|
678
|
+
connect: makeHubConnector(HUB_FETCH_CONFIG),
|
|
679
|
+
keepAliveTimeout: 10_000,
|
|
680
|
+
keepAliveMaxTimeout: 60_000,
|
|
681
|
+
pipelining: 1,
|
|
682
|
+
});
|
|
433
683
|
export function _getHubFetchConfigForTest(env) {
|
|
434
684
|
return env ? makeHubFetchTransportConfig(env) : {
|
|
435
685
|
...HUB_FETCH_CONFIG,
|
|
@@ -441,6 +691,9 @@ export function _getHubFetchConfigForTest(env) {
|
|
|
441
691
|
export function _shouldFallbackFromIpv4ForTest(err, hubIpFamily = HUB_FETCH_CONFIG.hubIpFamily) {
|
|
442
692
|
return shouldFallbackFromIpv4(err, hubIpFamily);
|
|
443
693
|
}
|
|
694
|
+
export function _configureHubSocketForTest(socket) {
|
|
695
|
+
configureHubSocket(socket);
|
|
696
|
+
}
|
|
444
697
|
// Test seam: lets unit tests swap the underlying fetch without forking the call path; production must never reassign it from outside.
|
|
445
698
|
let _fetchImpl = undiciFetch;
|
|
446
699
|
export function _setFetchImplForTest(fn) { _fetchImpl = fn ?? undiciFetch; }
|
|
@@ -458,7 +711,7 @@ function warnInsecureOnce() {
|
|
|
458
711
|
export function _resetInsecureWarningForTest() { _insecureWarned = false; }
|
|
459
712
|
/** Default production transport: secure mode = https guard + forced TLS dispatcher; escape-hatch mode = skip both (local dev). */
|
|
460
713
|
export const globalFetchLike = async (url, init) => {
|
|
461
|
-
const raw = init;
|
|
714
|
+
const raw = { ...init, redirect: 'manual' };
|
|
462
715
|
if (insecureAllowed(process.env)) {
|
|
463
716
|
warnInsecureOnce();
|
|
464
717
|
return (await _fetchImpl(url, raw));
|
package/dist/hubReuse.d.ts
CHANGED
|
@@ -5,6 +5,9 @@ type GeneCandidateInput = algo.GeneCandidateInput;
|
|
|
5
5
|
export declare const SEARCH_CACHE_TTL_MS: number;
|
|
6
6
|
export declare const SEARCH_CACHE_MAX = 200;
|
|
7
7
|
export declare const PAYLOAD_CACHE_MAX = 100;
|
|
8
|
+
export declare const SEMANTIC_SEARCH_LIMIT = 10;
|
|
9
|
+
export declare const SEMANTIC_QUERY_MAX_TERMS = 12;
|
|
10
|
+
export declare const SEMANTIC_QUERY_MAX_CHARS = 512;
|
|
8
11
|
/** Default reuse mode (ported from v1): 'reference' injects the asset as a strong hint; 'direct' applies it. */
|
|
9
12
|
export type ReuseMode = 'direct' | 'reference';
|
|
10
13
|
export declare const DEFAULT_REUSE_MODE: ReuseMode;
|
|
@@ -12,8 +15,23 @@ export declare const DEFAULT_REUSE_MODE: ReuseMode;
|
|
|
12
15
|
export declare function getMinReuseScore(env?: NodeJS.ProcessEnv): number;
|
|
13
16
|
/** Reads EVOLVER_REUSE_MODE here (adapter concern). */
|
|
14
17
|
export declare function getReuseMode(env?: NodeJS.ProcessEnv): ReuseMode;
|
|
18
|
+
/** V1-compatible kill-switch. Semantic recall is on unless explicitly disabled. */
|
|
19
|
+
export declare function isSemanticSearchEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Derive a bounded public semantic query from structured signal tags. Error signatures, paths, prose, and other
|
|
22
|
+
* unstructured values are excluded so the vector-search leg cannot become a side channel for local diagnostics.
|
|
23
|
+
*/
|
|
24
|
+
export declare function buildSemanticQuery(signals: readonly string[]): string;
|
|
15
25
|
/** Stable signal fingerprint (ported from v1 _cacheKey: sort + join). */
|
|
16
26
|
export declare function signalFingerprint(signals: readonly string[]): string;
|
|
27
|
+
export declare const TASK_DOMAIN_SIGNAL_PREFIX: "task_domain:";
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the hub-side domain fence from this turn's signals. Exactly one domain is used and only
|
|
30
|
+
* when the turn is unambiguous: with two or more distinct task_domain:* signals the turn spans
|
|
31
|
+
* domains, and scoping recall to either one would hide the other's assets — so we return null and
|
|
32
|
+
* fall back to unscoped recall (today's behaviour).
|
|
33
|
+
*/
|
|
34
|
+
export declare function hubDomainFromSignals(signals: readonly string[]): string | null;
|
|
17
35
|
/**
|
|
18
36
|
* The two-layer reuse cache. Bounded + TTL'd, per-process. A search-cache hit means phase 1 makes ZERO hub
|
|
19
37
|
* calls; a payload-cache hit means phase 3 makes ZERO hub calls. The clock is injected for deterministic tests.
|
|
@@ -55,6 +73,8 @@ export interface ReuseBeforeSolveOptions {
|
|
|
55
73
|
searchLimit?: number;
|
|
56
74
|
/** Reuse mode label carried into the result (direct/reference). */
|
|
57
75
|
mode?: ReuseMode;
|
|
76
|
+
/** Environment snapshot for adapter-owned reuse settings and the semantic-search kill-switch. */
|
|
77
|
+
env?: NodeJS.ProcessEnv;
|
|
58
78
|
/** Observability sink (asset-call log). Receives structured records; never throws. */
|
|
59
79
|
log?: {
|
|
60
80
|
append(entry: Record<string, unknown>): void;
|
|
@@ -97,6 +117,26 @@ export interface ReuseBeforeSolveResult {
|
|
|
97
117
|
/** Why we didn't reuse, when action === 'solve-fresh' (no_signals / no_results / below_threshold). */
|
|
98
118
|
reason?: string;
|
|
99
119
|
}
|
|
120
|
+
export interface HubMetadataSearchOptions {
|
|
121
|
+
/** Environment snapshot for the semantic-search kill-switch. */
|
|
122
|
+
env?: NodeJS.ProcessEnv;
|
|
123
|
+
/** Cap on the signal-search leg. The semantic leg keeps its own bounded limit. */
|
|
124
|
+
searchLimit?: number;
|
|
125
|
+
}
|
|
126
|
+
export interface HubMetadataSearchResult {
|
|
127
|
+
signals: string[];
|
|
128
|
+
fingerprint: string;
|
|
129
|
+
metadata: hub.HubMetadata[];
|
|
130
|
+
searchCached: boolean;
|
|
131
|
+
/** False when either free search leg failed. Incomplete results must never prove a miss. */
|
|
132
|
+
complete: boolean;
|
|
133
|
+
error?: unknown;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Run the complete free-search phase shared by reuse and economic miss probes. This function never performs the
|
|
137
|
+
* paid fetch. Only complete dual-leg results enter the cache, so a partial outage cannot become a verified miss.
|
|
138
|
+
*/
|
|
139
|
+
export declare function searchHubMetadata(cap: hub.HubCapability, cache: ReuseCache, signals: readonly string[], opts?: HubMetadataSearchOptions): Promise<HubMetadataSearchResult>;
|
|
100
140
|
/**
|
|
101
141
|
* The reuse-before-solve flow. Returns the single winner (already fetched) as a selection candidate, or a
|
|
102
142
|
* solve-fresh verdict. Never throws on a hub error — reuse is an optimization, not a hard dependency:
|