@evomap/evolver-adapter-public 2.0.0-beta.19 → 2.0.0-beta.22

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.
@@ -1,12 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { bootstrap, hub as hubNs, signals } from '@evomap/evolver-core';
2
+ import { bootstrap, hub as hubNs, signals, wire } from '@evomap/evolver-core';
3
3
  import { AuthError, HubFetch, HubClientError, isHubUnreachableError } from './hubFetch.js';
4
4
  import { isNodeSecret, parseNodeSecretVersion } from './auth/legacyShim.js';
5
5
  import { inboundToAgentEvent, agentEventToOutbound, publishRespToReceipt, searchQueryToFetchWire, searchQueryToSearchOnlyWire, } from './wireMap.js';
6
6
  import { antiAbuseTelemetryMode, buildHeartbeatAntiAbuseTelemetry, } from './antiAbuseTelemetry.js';
7
7
  import { getWorkspaceKeychainMode } from './auth/workspaceKeychain.js';
8
8
  import { agentDirectoryFailure, parsePublicAgentPage, parsePublicAgentProfile, paginatePublicAgentPage, mergePublicAgentPages, publicAgentSearchQuery, publicTaskDiscoveryQuery, PUBLIC_TASK_DISCOVERY_MAX_CANDIDATES, unsupportedPublicAvailability, unsupportedPublicSort, withDirectoryTimeout, } from './agentDirectory.js';
9
- import { assetMatchesId } from './hubReuse.js';
9
+ import { assetMatchesId, stripHubDeliveryMetadataForIntegrity } from './hubReuse.js';
10
10
  export const INBOUND_LIMIT = 100;
11
11
  export const OUTBOUND_MAX_BATCH = 50;
12
12
  export const OUTBOUND_MAX_BODY_BYTES = 4 * 1024 * 1024;
@@ -22,6 +22,12 @@ export const USED_ASSET_IDS_MAX = 50;
22
22
  export const USED_ASSET_ID_MAX_LEN = 200;
23
23
  export const LEARNING_ASSET_IDS_MAX = 50;
24
24
  export const LEARNING_ASSET_ID_MAX_LEN = 128;
25
+ export class MalformedAccountAssetPageError extends Error {
26
+ constructor() {
27
+ super('Hub account asset page is malformed');
28
+ this.name = 'MalformedAccountAssetPageError';
29
+ }
30
+ }
25
31
  /** 完整 GEP-A2A 信封(实测 dev: publish/fetch/validate 等协议消息端点必须全信封, 非仅 protocol+message_type). */
26
32
  export function gepEnvelope(messageType, payload, options = {}) {
27
33
  return {
@@ -97,15 +103,35 @@ export class PublicHubCapability {
97
103
  publish: async (recipeId, options) => this.publishRecipe(recipeId, options),
98
104
  get: async (recipeId) => this.getRecipe(recipeId),
99
105
  express: async (recipeId, request = {}) => this.expressRecipe(recipeId, request),
106
+ search: async (request = {}) => this.searchRecipes(request),
107
+ list: async (request = {}) => this.listRecipes(request),
100
108
  };
101
109
  constructor(opts) {
102
110
  this.opts = opts;
103
111
  this.auth = opts.auth;
104
112
  this.http = new HubFetch({ baseUrl: opts.baseUrl, auth: opts.auth, fetchFn: opts.fetchFn, senderId: opts.senderId });
105
113
  }
114
+ evolverVersionForWire(explicitVersion) {
115
+ const antiAbuse = this.opts.antiAbuse;
116
+ return bootstrap.normalizeEvolverVersion(explicitVersion !== undefined
117
+ ? explicitVersion
118
+ : antiAbuse?.evolverVersion ?? antiAbuse?.envFingerprint?.evolver_version);
119
+ }
120
+ envFingerprintForWire(evolverVersion) {
121
+ const fingerprint = {
122
+ ...(this.opts.antiAbuse?.envFingerprint
123
+ ?? bootstrap.captureEnvFingerprint({ env: this.opts.antiAbuse?.env ?? process.env })),
124
+ };
125
+ if (evolverVersion)
126
+ fingerprint.evolver_version = evolverVersion;
127
+ else
128
+ delete fingerprint.evolver_version;
129
+ return fingerprint;
130
+ }
106
131
  async hello(opts) {
107
132
  try {
108
133
  const sender = this.opts.senderId();
134
+ const evolverVersion = this.evolverVersionForWire(opts.evolverVersion);
109
135
  const body = await this.http.call('POST', '/a2a/hello', gepEnvelope('hello', {
110
136
  rotate_secret: opts.rotate,
111
137
  capabilities: { supported_types: ['publish', 'fetch', 'mailbox', 'questions'] },
@@ -113,12 +139,12 @@ export class PublicHubCapability {
113
139
  status: 'active',
114
140
  timestamp: new Date().toISOString(),
115
141
  ...(sender ? { node_id: sender } : {}),
116
- ...(opts.evolverVersion ? { evolver_version: opts.evolverVersion } : {}),
142
+ ...(evolverVersion ? { evolver_version: evolverVersion } : {}),
117
143
  // v1 parity (a2aProtocol.js buildHello): every hello carries the env fingerprint — it is how the
118
144
  // hub builds node/IP trust for its anti-abuse layer. v2 had moved it to heartbeat-only meta, which
119
145
  // one-shot CLI paths never send; the hub then answers heartbeats with resend_hello
120
146
  // `missing_env_fingerprint` and 403-antibodies /a2a/fetch (#555).
121
- env_fingerprint: bootstrap.captureEnvFingerprint({ env: this.opts.antiAbuse?.env ?? process.env }),
147
+ env_fingerprint: this.envFingerprintForWire(evolverVersion),
122
148
  }));
123
149
  const payload = asRecord(body['payload']) ?? body;
124
150
  const retryAfterMs = numberField(payload, 'retry_after_ms') ?? numberField(payload, 'retryAfterMs');
@@ -185,12 +211,13 @@ export class PublicHubCapability {
185
211
  async heartbeat(opts = {}) {
186
212
  try {
187
213
  const nodeSecretVersion = this.auth.getNodeSecretVersion?.();
188
- const meta = this.heartbeatMeta(opts, nodeSecretVersion);
214
+ const evolverVersion = this.evolverVersionForWire(opts.evolverVersion);
215
+ const meta = this.heartbeatMeta(evolverVersion, nodeSecretVersion);
189
216
  const body = await this.http.call('POST', '/a2a/heartbeat', {
190
217
  ...(this.opts.senderId() ? { node_id: this.opts.senderId() } : {}),
191
218
  timestamp: new Date().toISOString(),
192
219
  status: 'active',
193
- ...(opts.evolverVersion ? { evolver_version: opts.evolverVersion } : {}),
220
+ ...(evolverVersion ? { evolver_version: evolverVersion } : {}),
194
221
  ...(opts.lastUpdate ? { last_update: opts.lastUpdate } : {}),
195
222
  ...(nodeSecretVersion !== undefined ? { node_secret_version: nodeSecretVersion } : {}),
196
223
  ...(meta ? { meta } : {}),
@@ -207,7 +234,7 @@ export class PublicHubCapability {
207
234
  throw err;
208
235
  }
209
236
  }
210
- heartbeatMeta(opts, nodeSecretVersion) {
237
+ heartbeatMeta(evolverVersion, nodeSecretVersion) {
211
238
  const meta = {};
212
239
  if (nodeSecretVersion !== undefined)
213
240
  meta['node_secret_version'] = nodeSecretVersion;
@@ -217,7 +244,7 @@ export class PublicHubCapability {
217
244
  meta['anti_abuse'] = buildHeartbeatAntiAbuseTelemetry({
218
245
  ...antiAbuse,
219
246
  nodeId: this.opts.senderId(),
220
- evolverVersion: opts.evolverVersion,
247
+ evolverVersion,
221
248
  });
222
249
  }
223
250
  catch (err) {
@@ -261,12 +288,56 @@ export class PublicHubCapability {
261
288
  const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', searchQueryToFetchWire(query)));
262
289
  return assetsFromBody(body);
263
290
  }
264
- async fetchAssetById(assetId) {
291
+ /**
292
+ * Fetch one asset AND say why, when the answer is not an asset. `fetchAssetById` collapses every outcome to
293
+ * `null`, so a caller could not tell "the hub does not have this" from "the hub delivered something the
294
+ * client refuses" — and the CLI reported both as `not_found` on assets that demonstrably exist (#964).
295
+ */
296
+ async fetchAssetDeliveryById(assetId, options) {
265
297
  const id = assetId.trim();
266
298
  if (!id)
267
- return null;
299
+ return { status: 'absent' };
268
300
  const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', { asset_ids: [id] }));
269
- return assetsFromBody(body).find((asset) => fetchResultMatchesId(asset, id)) ?? null;
301
+ const matches = [];
302
+ for (const row of assetCandidatesFromBody(body)) {
303
+ const asset = unwrapFetchDeliveryRow(row);
304
+ if (!fetchDeliveryIdentityConsistent(row, asset))
305
+ return { status: 'rejected', reason: 'identity_mismatch' };
306
+ if (isContentAssetIdRequest(id)) {
307
+ // Identity is what this gate is for: the delivery must bind the REQUESTED content id through its own
308
+ // canonical `asset_id`. Whether the delivered body then hashes to that id, or satisfies the wire schema,
309
+ // is a trust question the caller resolves (quarantine + repair) — not grounds to drop the asset here.
310
+ if (!assetMatchesId(asset, id) && stringField(asset, 'asset_id') !== id) {
311
+ return { status: 'rejected', reason: 'identity_mismatch' };
312
+ }
313
+ if (isRevokedFetchDelivery(row))
314
+ return { status: 'rejected', reason: 'revoked' };
315
+ matches.push(asset);
316
+ continue;
317
+ }
318
+ if (fetchResultMatchesId(asset, id)) {
319
+ if (isRevokedFetchDelivery(row))
320
+ return { status: 'rejected', reason: 'revoked' };
321
+ matches.push(asset);
322
+ }
323
+ }
324
+ if (matches.length === 0)
325
+ return { status: 'absent' };
326
+ const result = unambiguousFetchResult(matches);
327
+ if (!result)
328
+ return { status: 'rejected', reason: 'ambiguous' };
329
+ // Logical-id lookups retain their historical matching semantics. Only a canonical sha256 lookup can opt in
330
+ // to a content-hash drift, and every non-opted-in caller remains strict.
331
+ const verified = assetMatchesId(result, id);
332
+ if (verified || !isContentAssetIdRequest(id))
333
+ return { status: 'delivered', asset: result, verified: true };
334
+ return options?.allowUnverifiedExactIdentity === true
335
+ ? { status: 'delivered', asset: result, verified: false }
336
+ : { status: 'rejected', reason: 'unverified_not_allowed' };
337
+ }
338
+ async fetchAssetById(assetId, options) {
339
+ const outcome = await this.fetchAssetDeliveryById(assetId, options);
340
+ return outcome.status === 'delivered' ? outcome.asset : null;
270
341
  }
271
342
  /**
272
343
  * #69: search != fetch. Free-text is the hub's vector endpoint (GET /a2a/assets/semantic-search?q=);
@@ -349,16 +420,18 @@ export class PublicHubCapability {
349
420
  ...(opts.scope === 'published' && opts.status && opts.status !== 'all' ? { status: opts.status } : {}),
350
421
  };
351
422
  const body = await this.http.call('GET', path, undefined, query);
352
- const payload = asRecord(body['payload']) ?? body;
423
+ const payload = Object.prototype.hasOwnProperty.call(body, 'payload')
424
+ ? asRecord(body['payload'])
425
+ : body;
426
+ if (!payload)
427
+ throw new MalformedAccountAssetPageError();
353
428
  const assets = accountAssetsFromPayload(payload);
354
429
  const count = numberField(payload, 'count');
355
- const nextCursor = stringField(payload, 'next_cursor') ?? stringField(payload, 'nextCursor');
356
- const hasMore = booleanField(payload, 'has_more') ?? booleanField(payload, 'hasMore') ?? Boolean(nextCursor);
430
+ const pagination = accountPaginationFromPayload(payload);
357
431
  return {
358
432
  assets,
359
433
  ...(count !== undefined ? { count } : {}),
360
- hasMore,
361
- ...(nextCursor ? { nextCursor } : {}),
434
+ ...pagination,
362
435
  };
363
436
  }
364
437
  /**
@@ -562,6 +635,20 @@ export class PublicHubCapability {
562
635
  ...(recipe !== undefined ? { recipe } : {}),
563
636
  };
564
637
  }
638
+ async searchRecipes(request = {}) {
639
+ if (isHubDryRunEnabled()) {
640
+ return dryRunRecipeSearchReceipt('search_recipe', request);
641
+ }
642
+ const body = await this.http.call('GET', '/a2a/recipe/search', undefined, recipeSearchQuery(request));
643
+ return recipeSearchReceiptFromBody(body);
644
+ }
645
+ async listRecipes(request = {}) {
646
+ if (isHubDryRunEnabled()) {
647
+ return dryRunRecipeSearchReceipt('list_recipe', request);
648
+ }
649
+ const body = await this.http.call('GET', '/a2a/recipe/list', undefined, recipeSearchQuery(request));
650
+ return recipeSearchReceiptFromBody(body);
651
+ }
565
652
  async expressRecipe(recipeId, request = {}) {
566
653
  if (isHubDryRunEnabled()) {
567
654
  return dryRunRecipeReceipt('express_recipe', recipeId, { input_payload: request.inputPayload ?? {} });
@@ -807,6 +894,58 @@ function recipeOrganismIdFromPayload(payload) {
807
894
  ? stringField(organism, 'id') ?? stringField(organism, 'organism_id') ?? stringField(organism, 'organismId')
808
895
  : undefined;
809
896
  }
897
+ function recipeSearchQuery(request) {
898
+ return {
899
+ ...(request.q ? { q: request.q } : {}),
900
+ ...(request.limit !== undefined ? { limit: request.limit } : {}),
901
+ ...(request.cursor ? { cursor: request.cursor } : {}),
902
+ ...(request.sort ? { sort: request.sort } : {}),
903
+ };
904
+ }
905
+ function recipeListFromRecord(value) {
906
+ for (const key of ['recipes', 'items', 'results']) {
907
+ const found = value[key];
908
+ if (Array.isArray(found))
909
+ return found;
910
+ }
911
+ const nested = asRecord(value['data']);
912
+ if (!nested)
913
+ return undefined;
914
+ for (const key of ['recipes', 'items', 'results']) {
915
+ const found = nested[key];
916
+ if (Array.isArray(found))
917
+ return found;
918
+ }
919
+ return undefined;
920
+ }
921
+ function recipeSearchReceiptFromBody(body) {
922
+ const payload = recipePayload(body);
923
+ const recipes = recipeListFromRecord(payload) ?? recipeListFromRecord(body) ?? [];
924
+ const nextCursor = stringField(payload, 'next_cursor')
925
+ ?? stringField(payload, 'nextCursor')
926
+ ?? stringField(body, 'next_cursor')
927
+ ?? stringField(body, 'nextCursor');
928
+ const hasMore = booleanField(payload, 'has_more')
929
+ ?? booleanField(payload, 'hasMore')
930
+ ?? booleanField(body, 'has_more')
931
+ ?? booleanField(body, 'hasMore');
932
+ return {
933
+ recipes,
934
+ ...(nextCursor ? { nextCursor } : {}),
935
+ ...(hasMore !== undefined ? { hasMore } : {}),
936
+ raw: body,
937
+ };
938
+ }
939
+ function dryRunRecipeSearchReceipt(action, request) {
940
+ return {
941
+ recipes: [],
942
+ raw: {
943
+ dry_run: true,
944
+ would: action,
945
+ ...request,
946
+ },
947
+ };
948
+ }
810
949
  function recipeReceiptFromBody(body) {
811
950
  const payload = recipePayload(body);
812
951
  const recipe = asRecord(payload['recipe']);
@@ -831,7 +970,7 @@ function dryRunRecipeReceipt(action, recipeId, extra = {}) {
831
970
  },
832
971
  };
833
972
  }
834
- function assetsFromBody(body) {
973
+ function assetCandidatesFromBody(body) {
835
974
  const payload = asRecord(body['payload']);
836
975
  const candidates = [
837
976
  body['asset'],
@@ -842,15 +981,25 @@ function assetsFromBody(body) {
842
981
  ...(Array.isArray(payload?.['results']) ? payload['results'] : []),
843
982
  ];
844
983
  return candidates
845
- .filter((candidate) => Boolean(candidate && typeof candidate === 'object' && !Array.isArray(candidate)))
846
- .map(unwrapFetchDeliveryRow);
984
+ .filter((candidate) => Boolean(candidate && typeof candidate === 'object' && !Array.isArray(candidate)));
985
+ }
986
+ function assetsFromBody(body) {
987
+ return assetCandidatesFromBody(body).map(unwrapFetchDeliveryRow);
847
988
  }
848
989
  // Delivery-row metadata carried over onto the unwrapped GEP record. Ranking fields are consumed by
849
990
  // hubReuse and stripped before canonical storage. `payload_backfill_reason` must also survive this
850
991
  // boundary so integrity consumers can report that the Hub synthesized the payload (#570). Do not
851
992
  // carry `confidence`: it is transport metadata on Gene rows but canonical content on Capsules, so
852
993
  // overloading it can either poison a Gene hash or overwrite Capsule content (#565).
853
- const FETCH_ROW_CARRYOVER_KEYS = ['gdi_score', 'success_rate', 'reuse_count', 'source_node_id', 'payload_backfill_reason'];
994
+ const FETCH_ROW_CARRYOVER_KEYS = [
995
+ 'gdi_score',
996
+ 'success_rate',
997
+ 'reuse_count',
998
+ 'source_node_id',
999
+ 'payload_backfill_reason',
1000
+ 'status',
1001
+ 'trust_state',
1002
+ ];
854
1003
  /**
855
1004
  * The live hub's /a2a/fetch results are DELIVERY ROWS, not raw GEP records (#565, observed on
856
1005
  * evomap.ai 2026-07-22): the record itself nests under `payload`, while the row's own keys are
@@ -874,20 +1023,89 @@ function unwrapFetchDeliveryRow(row) {
874
1023
  }
875
1024
  return { ...inner, ...carryover };
876
1025
  }
1026
+ function fetchDeliveryIdentityConsistent(row, asset) {
1027
+ const outer = row;
1028
+ if (typeof outer['type'] === 'string') {
1029
+ const transportType = stringField(outer, 'asset_type');
1030
+ if (transportType !== undefined && transportType !== outer['type'])
1031
+ return false;
1032
+ const transportLogicalId = stringField(outer, 'local_id');
1033
+ if (transportLogicalId !== undefined && transportLogicalId !== stringField(outer, 'id'))
1034
+ return false;
1035
+ return true;
1036
+ }
1037
+ const inner = asRecord(outer['payload']);
1038
+ if (!inner)
1039
+ return false;
1040
+ const outerAssetId = stringField(outer, 'asset_id');
1041
+ const innerAssetId = stringField(inner, 'asset_id');
1042
+ if (!outerAssetId || !innerAssetId || outerAssetId !== innerAssetId)
1043
+ return false;
1044
+ const outerType = stringField(outer, 'asset_type');
1045
+ const innerType = stringField(inner, 'type');
1046
+ if (outerType !== undefined && outerType !== innerType)
1047
+ return false;
1048
+ const innerLogicalId = stringField(inner, 'id');
1049
+ for (const key of ['id', 'local_id']) {
1050
+ const outerLogicalId = stringField(outer, key);
1051
+ if (outerLogicalId !== undefined && outerLogicalId !== innerLogicalId)
1052
+ return false;
1053
+ }
1054
+ for (const key of ['status', 'trust_state']) {
1055
+ const outerMarker = stringField(outer, key);
1056
+ const innerMarker = stringField(inner, key);
1057
+ const outerRevoked = isRevokedMarker(outerMarker);
1058
+ const innerRevoked = isRevokedMarker(innerMarker);
1059
+ if (outerMarker !== undefined && innerMarker !== undefined && outerRevoked !== innerRevoked)
1060
+ return false;
1061
+ }
1062
+ return asset === row || stringField(asset, 'asset_id') === innerAssetId;
1063
+ }
1064
+ function isRevokedFetchDelivery(row) {
1065
+ const record = row;
1066
+ const nested = asRecord(record['payload']);
1067
+ return [record, ...(nested ? [nested] : [])].some((value) => (isRevokedMarker(value['status'])
1068
+ || isRevokedMarker(value['trust_state'])));
1069
+ }
1070
+ function isRevokedMarker(raw) {
1071
+ return typeof raw === 'string' && raw.trim().toLowerCase() === 'revoked';
1072
+ }
877
1073
  function accountAssetsFromPayload(payload) {
878
- const candidates = [
879
- payload['assets'],
880
- payload['results'],
881
- payload['items'],
882
- ];
883
- for (const candidate of candidates) {
884
- if (!Array.isArray(candidate))
885
- continue;
886
- return candidate
887
- .filter((asset) => Boolean(asset && typeof asset === 'object' && !Array.isArray(asset)))
888
- .map(unwrapFetchDeliveryRow);
1074
+ const keys = ['assets', 'results', 'items']
1075
+ .filter((key) => Object.prototype.hasOwnProperty.call(payload, key));
1076
+ if (keys.length !== 1)
1077
+ throw new MalformedAccountAssetPageError();
1078
+ const candidate = payload[keys[0]];
1079
+ if (!Array.isArray(candidate) || candidate.some((asset) => !asset || typeof asset !== 'object' || Array.isArray(asset))) {
1080
+ throw new MalformedAccountAssetPageError();
889
1081
  }
890
- return [];
1082
+ return candidate.map((row) => {
1083
+ const asset = unwrapFetchDeliveryRow(row);
1084
+ return fetchDeliveryIdentityConsistent(row, asset) ? asset : row;
1085
+ });
1086
+ }
1087
+ function accountPaginationFromPayload(payload) {
1088
+ const snakeHasMore = payload['has_more'];
1089
+ const camelHasMore = payload['hasMore'];
1090
+ if ((snakeHasMore !== undefined && typeof snakeHasMore !== 'boolean')
1091
+ || (camelHasMore !== undefined && typeof camelHasMore !== 'boolean')
1092
+ || (snakeHasMore === undefined && camelHasMore === undefined)
1093
+ || (typeof snakeHasMore === 'boolean' && typeof camelHasMore === 'boolean' && snakeHasMore !== camelHasMore)) {
1094
+ throw new MalformedAccountAssetPageError();
1095
+ }
1096
+ const hasMore = typeof snakeHasMore === 'boolean' ? snakeHasMore : camelHasMore;
1097
+ const rawCursors = [payload['next_cursor'], payload['nextCursor']]
1098
+ .filter((value) => value !== undefined && value !== null);
1099
+ if (rawCursors.some((value) => typeof value !== 'string' || !value.trim())) {
1100
+ throw new MalformedAccountAssetPageError();
1101
+ }
1102
+ if (rawCursors.length === 2 && rawCursors[0] !== rawCursors[1]) {
1103
+ throw new MalformedAccountAssetPageError();
1104
+ }
1105
+ const nextCursor = rawCursors[0];
1106
+ if (hasMore !== Boolean(nextCursor))
1107
+ throw new MalformedAccountAssetPageError();
1108
+ return { hasMore, ...(nextCursor ? { nextCursor } : {}) };
891
1109
  }
892
1110
  function learningAssetsFromPayload(payload) {
893
1111
  const candidates = [
@@ -991,7 +1209,30 @@ function failureReason(error) {
991
1209
  function fetchResultMatchesId(asset, requestedId) {
992
1210
  if (assetMatchesId(asset, requestedId))
993
1211
  return true;
994
- return !requestedId.startsWith('sha256:') && Boolean(asset && stringField(asset, 'id') === requestedId);
1212
+ if (!asset)
1213
+ return false;
1214
+ if (!requestedId.startsWith('sha256:'))
1215
+ return stringField(asset, 'id') === requestedId;
1216
+ return false;
1217
+ }
1218
+ function unambiguousFetchResult(matches) {
1219
+ if (matches.length === 0)
1220
+ return null;
1221
+ let canonical;
1222
+ try {
1223
+ canonical = wire.canonicalize(stripHubDeliveryMetadataForIntegrity(matches[0]));
1224
+ for (const asset of matches.slice(1)) {
1225
+ if (wire.canonicalize(stripHubDeliveryMetadataForIntegrity(asset)) !== canonical)
1226
+ return null;
1227
+ }
1228
+ }
1229
+ catch {
1230
+ return null;
1231
+ }
1232
+ return matches.find((asset) => stringField(asset, 'payload_backfill_reason') !== undefined) ?? matches[0];
1233
+ }
1234
+ function isContentAssetIdRequest(requestedId) {
1235
+ return /^sha256:[0-9a-f]{64}$/.test(requestedId);
995
1236
  }
996
1237
  function stringField(value, key) {
997
1238
  return typeof value[key] === 'string' && value[key].length > 0 ? value[key] : undefined;
@@ -84,9 +84,9 @@ export interface HubFetchDeps {
84
84
  deadlineScheduler?: HubDeadlineScheduler;
85
85
  }
86
86
  /**
87
- * 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证: POST 通常注入 body; GET 与 strict hello envelope
88
- * 走 **Authorization: Bearer <node_secret>** 头(hub 只从 header/body node_secret, 绝不从 query — #8);
89
- * sender_id 是标识非凭证, 留 query/body.
87
+ * 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证: legacy node_secret GET 与 strict
88
+ * GEP envelope POST 走 **Authorization: Bearer <node_secret>** 头,绝不进入 query envelope body;
89
+ * 其余兼容 REST POST 保留既有 body contract。sender_id 是标识非凭证, 留 query/body.
90
90
  * 401/403→AuthError(reauth), 4xx→HubClientError(终态), 5xx→重试.
91
91
  * 非 JSON Hub 响应(WAF/HTML/captive portal/gateway text)→HubUnreachableError, 不触发 auth recovery.
92
92
  */
package/dist/hubFetch.js CHANGED
@@ -72,6 +72,46 @@ const PROTECTED_REQUEST_HEADERS = new Set([
72
72
  'x-evomap-signature',
73
73
  'x-node-secret',
74
74
  ]);
75
+ const LEGACY_BEARER_POST_PATHS = new Set([
76
+ '/a2a/hello',
77
+ '/a2a/publish',
78
+ '/a2a/validate',
79
+ '/a2a/fetch',
80
+ '/a2a/events/poll',
81
+ '/a2a/mailbox/outbound',
82
+ ]);
83
+ function requestHeaderName(headers, name) {
84
+ const normalized = name.toLowerCase();
85
+ return Object.keys(headers).find((headerName) => headerName.toLowerCase() === normalized);
86
+ }
87
+ function setLegacyBearerFallback(headers, nodeSecret) {
88
+ const existingName = requestHeaderName(headers, 'authorization');
89
+ if (existingName !== undefined && headers[existingName]?.trim())
90
+ return false;
91
+ if (existingName !== undefined)
92
+ delete headers[existingName];
93
+ headers['authorization'] = `Bearer ${nodeSecret}`;
94
+ return true;
95
+ }
96
+ function legacyNodeSecret(value) {
97
+ return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value) ? value : undefined;
98
+ }
99
+ function isGepEnvelope(body) {
100
+ return body?.['protocol'] === 'gep-a2a'
101
+ && body['protocol_version'] === '1.0.0'
102
+ && typeof body['message_type'] === 'string'
103
+ && body['message_type'].trim().length > 0
104
+ && typeof body['message_id'] === 'string'
105
+ && body['message_id'].trim().length > 0
106
+ && typeof body['timestamp'] === 'string'
107
+ && Number.isFinite(Date.parse(body['timestamp']))
108
+ && Object.prototype.hasOwnProperty.call(body, 'payload')
109
+ && body['payload'] !== undefined;
110
+ }
111
+ function usesLegacyBearerForPost(method, path, body) {
112
+ return method.toUpperCase() === 'POST'
113
+ && (LEGACY_BEARER_POST_PATHS.has(path) || isGepEnvelope(body));
114
+ }
75
115
  function mergeRequestHeaders(requestHeaders, signedHeaders) {
76
116
  const signedNames = new Set(Object.keys(signedHeaders ?? {}).map((name) => name.toLowerCase()));
77
117
  const headers = {};
@@ -93,9 +133,9 @@ function mergeRequestHeaders(requestHeaders, signedHeaders) {
93
133
  return { ...headers, ...signedHeaders };
94
134
  }
95
135
  /**
96
- * 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证: POST 通常注入 body; GET 与 strict hello envelope
97
- * 走 **Authorization: Bearer <node_secret>** 头(hub 只从 header/body node_secret, 绝不从 query — #8);
98
- * sender_id 是标识非凭证, 留 query/body.
136
+ * 公版 hub HTTP 客户端(M6-6). 每请求经 AuthProvider 取凭证: legacy node_secret GET 与 strict
137
+ * GEP envelope POST 走 **Authorization: Bearer <node_secret>** 头,绝不进入 query envelope body;
138
+ * 其余兼容 REST POST 保留既有 body contract。sender_id 是标识非凭证, 留 query/body.
99
139
  * 401/403→AuthError(reauth), 4xx→HubClientError(终态), 5xx→重试.
100
140
  * 非 JSON Hub 响应(WAF/HTML/captive portal/gateway text)→HubUnreachableError, 不触发 auth recovery.
101
141
  */
@@ -148,26 +188,28 @@ export class HubFetch {
148
188
  qs.set(k, String(v)); // non-credential GET params (e.g. semantic-search q)
149
189
  // #8: credentials must NOT go in the query (leaks to access logs / proxies even over https).
150
190
  // 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)}`;
191
+ const nodeSecret = legacyNodeSecret(creds['node_secret']);
192
+ if (nodeSecret !== undefined)
193
+ setLegacyBearerFallback(headers, nodeSecret);
154
194
  const q = qs.toString();
155
195
  if (q)
156
196
  url += `?${q}`;
157
197
  }
158
198
  else {
159
199
  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'];
200
+ const postBody = { ...(bodyObj ?? {}) };
201
+ const nodeSecret = legacyNodeSecret(postCreds['node_secret']);
202
+ if (usesLegacyBearerForPost(method, path, bodyObj)) {
203
+ delete postBody['node_secret'];
204
+ if (nodeSecret !== undefined && setLegacyBearerFallback(headers, nodeSecret)) {
205
+ delete postCreds['node_secret'];
206
+ }
165
207
  }
166
208
  if (path === '/a2a/mailbox/outbound' && sender) {
167
209
  const qs = new URLSearchParams({ sender_id: sender });
168
210
  url += `?${qs.toString()}`;
169
211
  }
170
- body = JSON.stringify({ ...(sender ? { sender_id: sender } : {}), ...postCreds, ...(bodyObj ?? {}) });
212
+ body = JSON.stringify({ ...(sender ? { sender_id: sender } : {}), ...postCreds, ...postBody });
171
213
  }
172
214
  let res;
173
215
  try {
@@ -246,7 +288,7 @@ function hubOperationForRequest(path, bodyObj) {
246
288
  && payload['search_only'] === true)
247
289
  return 'search';
248
290
  }
249
- if (path === '/a2a/assets/semantic-search' || path === '/a2a/directory/search')
291
+ if (path === '/a2a/assets/semantic-search' || path === '/a2a/directory/search' || path === '/a2a/recipe/search' || path === '/a2a/recipe/list')
250
292
  return 'search';
251
293
  if (path === '/a2a/heartbeat')
252
294
  return 'heartbeat';
@@ -24,7 +24,7 @@ export declare function isSemanticSearchEnabled(env?: NodeJS.ProcessEnv): boolea
24
24
  export declare function buildSemanticQuery(signals: readonly string[]): string;
25
25
  /** Stable signal fingerprint (ported from v1 _cacheKey: sort + join). */
26
26
  export declare function signalFingerprint(signals: readonly string[]): string;
27
- export declare const TASK_DOMAIN_SIGNAL_PREFIX = "task_domain:";
27
+ export declare const TASK_DOMAIN_SIGNAL_PREFIX: "task_domain:";
28
28
  /**
29
29
  * Resolve the hub-side domain fence from this turn's signals. Exactly one domain is used and only
30
30
  * when the turn is unambiguous: with two or more distinct task_domain:* signals the turn spans
@@ -50,6 +50,7 @@ export declare class ReuseCache {
50
50
  setPayload(assetId: string, payload: hub.AssetRecord): void;
51
51
  clear(): void;
52
52
  }
53
+ export declare function stripHubDeliveryMetadataForIntegrity(rec: hub.AssetRecord): hub.AssetRecord;
53
54
  /**
54
55
  * Map a hub search row (AssetRecord with arbitrary quality fields) → the core's price-free HubMetadata.
55
56
  * Accepts both camelCase and the hub's snake_case (gdi_score / success_rate / reuse_count / ...). Drops any