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

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.
@@ -15,11 +15,11 @@ export interface TokenResp {
15
15
  }
16
16
  /** 注入的 OAuth device flow 传输(M6-6 真 HTTP; 测试用桩). refresh 走 refresh_token grant. */
17
17
  export interface OAuthTransport {
18
- requestDeviceCode: (fingerprint: string) => Promise<DeviceCodeResp>;
19
- pollToken: (deviceCode: string) => Promise<TokenResp | {
18
+ requestDeviceCode: (fingerprint: string, signal?: AbortSignal) => Promise<DeviceCodeResp>;
19
+ pollToken: (deviceCode: string, signal?: AbortSignal) => Promise<TokenResp | {
20
20
  pending: true;
21
21
  }>;
22
- refresh?: (refreshToken: string) => Promise<TokenResp>;
22
+ refresh?: (refreshToken: string, signal?: AbortSignal) => Promise<TokenResp>;
23
23
  }
24
24
  export interface PublicOAuthOptions {
25
25
  credPath: string;
@@ -32,6 +32,8 @@ export interface PublicOAuthOptions {
32
32
  sleep?: (ms: number) => Promise<void>;
33
33
  /** 整个 device flow 的最长等待(默认 15min); 超时抛 device_flow_timeout. */
34
34
  maxWaitMs?: number;
35
+ /** Provider-level refresh bound, including injected transports. */
36
+ refreshTimeoutMs?: number;
35
37
  }
36
38
  /**
37
39
  * device token 认证(M6-5, 默认路径 evolver login). device code flow(学 gh auth login):
@@ -45,6 +47,7 @@ export declare class PublicOAuthProvider implements hub.AuthProvider {
45
47
  private readonly now;
46
48
  private readonly sleep;
47
49
  private readonly maxWaitMs;
50
+ private readonly refreshTimeoutMs;
48
51
  constructor(opts: PublicOAuthOptions);
49
52
  private fingerprint;
50
53
  /**
@@ -54,9 +57,9 @@ export declare class PublicOAuthProvider implements hub.AuthProvider {
54
57
  * itself (respecting the server interval); a terminal error from the transport
55
58
  * (access_denied / expired_token) propagates.
56
59
  */
57
- login(): Promise<hub.Credential>;
58
- authenticate(): Promise<hub.SignedRequest>;
59
- rotate(): Promise<hub.Credential>;
60
+ login(parentSignal?: AbortSignal): Promise<hub.Credential>;
61
+ authenticate(req?: hub.HttpRequestLike): Promise<hub.SignedRequest>;
62
+ rotate(parentSignal?: AbortSignal): Promise<hub.Credential>;
60
63
  revoke(): Promise<void>;
61
64
  private persist;
62
65
  }
@@ -2,6 +2,43 @@ import { CredentialStore } from './credentialStore.js';
2
2
  import { resolveMachineId, machineFingerprint } from './machineId.js';
3
3
  export const TOKEN_TTL_MS = 24 * 60 * 60_000; // fallback only; the hub supplies expiresInMs (~1h)
4
4
  export const ROTATE_BEFORE_MS = 5 * 60_000; // refresh 5min before expiry (hub access tokens are short-lived ~1h)
5
+ const DEFAULT_REFRESH_TIMEOUT_MS = 15_000;
6
+ function abortReason(signal) {
7
+ return signal.reason instanceof Error ? signal.reason : new Error('device_flow_timeout');
8
+ }
9
+ async function awaitWithAbort(promise, signal) {
10
+ const pending = Promise.resolve(promise);
11
+ if (signal.aborted) {
12
+ void pending.catch(() => { });
13
+ throw abortReason(signal);
14
+ }
15
+ return await new Promise((resolve, reject) => {
16
+ const cleanup = () => signal.removeEventListener('abort', onAbort);
17
+ const onAbort = () => {
18
+ cleanup();
19
+ reject(abortReason(signal));
20
+ };
21
+ signal.addEventListener('abort', onAbort, { once: true });
22
+ void pending.then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
23
+ });
24
+ }
25
+ function linkedTimeoutSignal(parent, timeoutMs, timeoutReason) {
26
+ const controller = new AbortController();
27
+ const abortFromParent = () => controller.abort(parent?.reason);
28
+ if (parent?.aborted)
29
+ abortFromParent();
30
+ else
31
+ parent?.addEventListener('abort', abortFromParent, { once: true });
32
+ const timeout = setTimeout(() => controller.abort(timeoutReason), timeoutMs);
33
+ timeout.unref?.();
34
+ return {
35
+ signal: controller.signal,
36
+ dispose: () => {
37
+ clearTimeout(timeout);
38
+ parent?.removeEventListener('abort', abortFromParent);
39
+ },
40
+ };
41
+ }
5
42
  /**
6
43
  * device token 认证(M6-5, 默认路径 evolver login). device code flow(学 gh auth login):
7
44
  * 显示 user code → 用户在浏览器授权 → 轮询拿 token. 绑机器指纹(CEO: OS machineId+软兜底).
@@ -14,12 +51,14 @@ export class PublicOAuthProvider {
14
51
  now;
15
52
  sleep;
16
53
  maxWaitMs;
54
+ refreshTimeoutMs;
17
55
  constructor(opts) {
18
56
  this.opts = opts;
19
57
  this.store = new CredentialStore(opts.credPath);
20
58
  this.now = opts.now ?? (() => Date.now());
21
59
  this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
22
60
  this.maxWaitMs = opts.maxWaitMs ?? 15 * 60_000;
61
+ this.refreshTimeoutMs = Math.max(1, opts.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS);
23
62
  }
24
63
  fingerprint() { return machineFingerprint(resolveMachineId(this.opts.machine).id); }
25
64
  /**
@@ -29,43 +68,57 @@ export class PublicOAuthProvider {
29
68
  * itself (respecting the server interval); a terminal error from the transport
30
69
  * (access_denied / expired_token) propagates.
31
70
  */
32
- async login() {
71
+ async login(parentSignal) {
33
72
  const cached = this.store.load();
34
73
  if (cached && 'token' in cached && (cached.expiresAt ?? 0) - ROTATE_BEFORE_MS > this.now())
35
74
  return cached;
36
75
  const fp = this.fingerprint();
37
- const dc = await this.opts.transport.requestDeviceCode(fp);
38
- (this.opts.onUserCode ?? ((d) => { process.stdout.write(`授权码 ${d.userCode} → ${d.verificationUri}\n`); }))(dc);
39
76
  const deadline = this.now() + this.maxWaitMs;
40
- let intervalMs = dc.intervalMs;
41
- for (;;) {
42
- const res = await this.opts.transport.pollToken(dc.deviceCode);
43
- if (!('pending' in res))
44
- return this.persist(res, fp);
45
- if (this.now() >= deadline)
46
- throw new Error('device_flow_timeout');
47
- await this.sleep(intervalMs);
48
- intervalMs += 1000; // gentle back-off; the hub's slow_down also surfaces as pending
77
+ const deadlineSignal = linkedTimeoutSignal(parentSignal, Math.max(0, this.maxWaitMs), new Error('device_flow_timeout'));
78
+ try {
79
+ const dc = await awaitWithAbort(this.opts.transport.requestDeviceCode(fp, deadlineSignal.signal), deadlineSignal.signal);
80
+ (this.opts.onUserCode ?? ((d) => { process.stdout.write(`授权码 ${d.userCode} → ${d.verificationUri}\n`); }))(dc);
81
+ let intervalMs = dc.intervalMs;
82
+ for (;;) {
83
+ if (this.now() >= deadline)
84
+ throw new Error('device_flow_timeout');
85
+ const res = await awaitWithAbort(this.opts.transport.pollToken(dc.deviceCode, deadlineSignal.signal), deadlineSignal.signal);
86
+ if (!('pending' in res))
87
+ return this.persist(res, fp);
88
+ if (this.now() >= deadline)
89
+ throw new Error('device_flow_timeout');
90
+ await awaitWithAbort(this.sleep(intervalMs), deadlineSignal.signal);
91
+ intervalMs += 1000; // gentle back-off; the hub's slow_down also surfaces as pending
92
+ }
93
+ }
94
+ finally {
95
+ deadlineSignal.dispose();
49
96
  }
50
97
  }
51
- async authenticate() {
98
+ async authenticate(req) {
52
99
  let cred = this.store.load();
53
100
  if (!cred || !('token' in cred))
54
101
  throw new Error('oauth 未登录, 先 login()');
55
102
  if ((cred.expiresAt ?? 0) - ROTATE_BEFORE_MS <= this.now())
56
- cred = await this.rotate();
103
+ cred = await this.rotate(req?.signal);
57
104
  return { headers: { authorization: `Bearer ${cred.token}`, 'x-evomap-device': cred.device ?? '' } };
58
105
  }
59
- async rotate() {
106
+ async rotate(parentSignal) {
60
107
  const cred = this.store.load();
61
108
  const fp = this.fingerprint();
62
109
  // Prefer a seamless refresh_token rotation; fall back to a fresh device-flow
63
110
  // login only when there is no usable refresh token (or no refresh transport).
64
111
  if (cred && 'refreshToken' in cred && cred.refreshToken && this.opts.transport.refresh) {
65
- const res = await this.opts.transport.refresh(cred.refreshToken);
66
- return this.persist(res, fp, cred.refreshToken);
112
+ const refreshSignal = linkedTimeoutSignal(parentSignal, this.refreshTimeoutMs, new Error('oauth_refresh_timeout'));
113
+ try {
114
+ const res = await awaitWithAbort(this.opts.transport.refresh(cred.refreshToken, refreshSignal.signal), refreshSignal.signal);
115
+ return this.persist(res, fp, cred.refreshToken);
116
+ }
117
+ finally {
118
+ refreshSignal.dispose();
119
+ }
67
120
  }
68
- return this.login();
121
+ return this.login(parentSignal);
69
122
  }
70
123
  async revoke() { }
71
124
  // fallbackRefresh: carry the prior refresh token forward when the hub's refresh
@@ -16,6 +16,10 @@ export interface OAuthHttpTransportOptions {
16
16
  scopes?: string[];
17
17
  /** Injected for tests; defaults to the secure global fetch (https guard + forced TLS). */
18
18
  fetchFn?: FetchLike;
19
+ /** Per-request deadline. The device-flow provider separately enforces its total deadline. */
20
+ timeoutMs?: number;
21
+ /** Defensive response cap; configurable only to keep boundary tests small. */
22
+ maxResponseBytes?: number;
19
23
  }
20
24
  /**
21
25
  * Real HTTP transport (M6-6) for the RFC 8628 device authorization grant. The
@@ -1,4 +1,4 @@
1
- import { assertHubUrlSecure, globalFetchLike } from '../hubFetch.js';
1
+ import { assertHubUrlSecure, drainHubResponse, globalFetchLike, HUB_GENERAL_TIMEOUT_MS, HUB_JSON_TEXT_MAX_BYTES, readHubResponseJson, } from '../hubFetch.js';
2
2
  const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
3
3
  /** First-party public client seeded on the hub (scripts/seed-evolver-oauth-client.js). */
4
4
  export const DEFAULT_CLIENT_ID = 'evolver-cli';
@@ -27,26 +27,77 @@ export function createOAuthHttpTransport(opts) {
27
27
  const clientId = opts.clientId ?? DEFAULT_CLIENT_ID;
28
28
  const scope = (opts.scopes ?? DEFAULT_SCOPES).join(' ');
29
29
  const fetchFn = opts.fetchFn ?? globalFetchLike;
30
- async function postJson(path, body) {
30
+ const timeoutMs = Math.max(1, opts.timeoutMs ?? HUB_GENERAL_TIMEOUT_MS);
31
+ const maxResponseBytes = Math.max(1, opts.maxResponseBytes ?? HUB_JSON_TEXT_MAX_BYTES);
32
+ async function postJson(path, body, parentSignal) {
31
33
  const url = `${base}${path}`;
32
34
  assertHubUrlSecure(url); // defense in depth; also enforced inside globalFetchLike
33
- const res = await fetchFn(url, {
34
- method: 'POST',
35
- headers: { 'content-type': 'application/json' },
36
- body: JSON.stringify(body),
37
- });
38
- const json = (await res.json().catch(() => ({})));
39
- return { status: res.status, json };
35
+ const controller = new AbortController();
36
+ const timeoutError = new Error(`${path} timed out after ${timeoutMs}ms`);
37
+ const timeout = setTimeout(() => controller.abort(timeoutError), timeoutMs);
38
+ timeout.unref?.();
39
+ const onParentAbort = () => controller.abort(parentSignal?.reason instanceof Error ? parentSignal.reason : new Error('device_flow_timeout'));
40
+ if (parentSignal?.aborted)
41
+ onParentAbort();
42
+ else
43
+ parentSignal?.addEventListener('abort', onParentAbort, { once: true });
44
+ const awaitRequest = async (promise) => {
45
+ const pending = Promise.resolve(promise);
46
+ if (controller.signal.aborted) {
47
+ void pending.catch(() => { });
48
+ throw controller.signal.reason;
49
+ }
50
+ return await new Promise((resolve, reject) => {
51
+ const cleanup = () => controller.signal.removeEventListener('abort', onAbort);
52
+ const onAbort = () => { cleanup(); reject(controller.signal.reason); };
53
+ controller.signal.addEventListener('abort', onAbort, { once: true });
54
+ void pending.then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
55
+ });
56
+ };
57
+ try {
58
+ const res = await awaitRequest(fetchFn(url, {
59
+ method: 'POST',
60
+ headers: { 'content-type': 'application/json' },
61
+ body: JSON.stringify(body),
62
+ redirect: 'manual',
63
+ signal: controller.signal,
64
+ }));
65
+ if (res.status >= 300 && res.status < 400) {
66
+ await drainHubResponse(res, { signal: controller.signal });
67
+ if (controller.signal.aborted)
68
+ throw controller.signal.reason;
69
+ throw new Error(`${path} refused an unexpected Hub redirect (HTTP ${res.status})`);
70
+ }
71
+ let json;
72
+ try {
73
+ json = await awaitRequest(readHubResponseJson(res, {
74
+ maxBytes: maxResponseBytes,
75
+ signal: controller.signal,
76
+ }));
77
+ }
78
+ catch (error) {
79
+ if (controller.signal.aborted)
80
+ throw controller.signal.reason;
81
+ if (!(error instanceof SyntaxError))
82
+ throw error;
83
+ json = {};
84
+ }
85
+ return { status: res.status, json };
86
+ }
87
+ finally {
88
+ clearTimeout(timeout);
89
+ parentSignal?.removeEventListener('abort', onParentAbort);
90
+ }
40
91
  }
41
92
  return {
42
- async requestDeviceCode(_fingerprint) {
93
+ async requestDeviceCode(_fingerprint, signal) {
43
94
  // The hub binds the device authorization to the client + the approving
44
95
  // user, not to a machine fingerprint, so the fingerprint is not sent here;
45
96
  // it is bound locally on the resulting credential (x-evomap-device header).
46
97
  const { status, json } = await postJson('/oauth/device_authorization', {
47
98
  client_id: clientId,
48
99
  scope,
49
- });
100
+ }, signal);
50
101
  if (status >= 400 || !json.device_code || !json.user_code) {
51
102
  throw new Error(`device_authorization failed (HTTP ${status}): ${json.error ?? 'no device_code'}`);
52
103
  }
@@ -57,12 +108,12 @@ export function createOAuthHttpTransport(opts) {
57
108
  intervalMs: (json.interval ?? 5) * 1000,
58
109
  };
59
110
  },
60
- async pollToken(deviceCode) {
111
+ async pollToken(deviceCode, signal) {
61
112
  const { status, json } = await postJson('/oauth/token', {
62
113
  grant_type: DEVICE_GRANT,
63
114
  device_code: deviceCode,
64
115
  client_id: clientId,
65
- });
116
+ }, signal);
66
117
  if (json.access_token) {
67
118
  return tokenResp(json);
68
119
  }
@@ -71,12 +122,12 @@ export function createOAuthHttpTransport(opts) {
71
122
  }
72
123
  throw new Error(`token poll failed (HTTP ${status}): ${json.error ?? 'unknown'}`);
73
124
  },
74
- async refresh(refreshToken) {
125
+ async refresh(refreshToken, signal) {
75
126
  const { status, json } = await postJson('/oauth/token', {
76
127
  grant_type: 'refresh_token',
77
128
  refresh_token: refreshToken,
78
129
  client_id: clientId,
79
- });
130
+ }, signal);
80
131
  if (!json.access_token) {
81
132
  throw new Error(`refresh failed (HTTP ${status}): ${json.error ?? 'no access_token'}`);
82
133
  }
@@ -59,7 +59,9 @@ export interface AccountAssetListResult {
59
59
  nextCursor?: string;
60
60
  }
61
61
  /** 完整 GEP-A2A 信封(实测 dev: publish/fetch/validate 等协议消息端点必须全信封, 非仅 protocol+message_type). */
62
- export declare function gepEnvelope(messageType: string, payload: unknown): Record<string, unknown>;
62
+ export declare function gepEnvelope(messageType: string, payload: unknown, options?: {
63
+ messageId?: string;
64
+ }): Record<string, unknown>;
63
65
  export interface PublicHubOptions {
64
66
  baseUrl: string;
65
67
  auth: hub.AuthProvider;
@@ -74,6 +76,8 @@ export interface PublicHelloResult {
74
76
  ok: boolean;
75
77
  authError?: boolean;
76
78
  nodeId?: string;
79
+ claimCode?: string;
80
+ claimUrl?: string;
77
81
  nodeSecretVersion?: number;
78
82
  rateLimitUntilMs?: number;
79
83
  error?: string;
@@ -124,6 +128,7 @@ export interface PublicHeartbeatResult {
124
128
  reason?: string;
125
129
  };
126
130
  forceUpdate?: PublicForceUpdateDirective;
131
+ capabilityGaps?: readonly string[];
127
132
  }
128
133
  export declare function isHubDryRunEnabled(env?: Record<string, string | undefined>): boolean;
129
134
  export declare function outboundMaxBodyBytes(env?: Record<string, string | undefined>): number;
@@ -140,12 +145,13 @@ export declare class PublicHubCapability implements hub.HubCapability {
140
145
  hello(opts: PublicHelloOptions): Promise<PublicHelloResult>;
141
146
  heartbeat(opts?: PublicHeartbeatOptions): Promise<PublicHeartbeatResult>;
142
147
  private heartbeatMeta;
143
- publish(bundle: hub.AssetRecord[]): Promise<hub.PublishReceipt>;
148
+ publish(bundle: hub.AssetRecord[], options?: hub.PublishOptions): Promise<hub.PublishReceipt>;
144
149
  fetch(query: hub.HubQuery): Promise<hub.AssetRecord[]>;
145
150
  fetchAssetById(assetId: string): Promise<hub.AssetRecord | null>;
146
151
  /**
147
152
  * #69: search != fetch. Free-text is the hub's vector endpoint (GET /a2a/assets/semantic-search?q=);
148
- * signals/id queries fall through to fetch. /a2a/fetch does NOT do semantic, so text must not go there.
153
+ * signal/id queries use the Hub's free search-only phase on /a2a/fetch. /a2a/fetch does NOT do semantic,
154
+ * so text must not go there and paid/full fetch must remain an explicit follow-up.
149
155
  */
150
156
  search(query: hub.HubQuery): Promise<hub.AssetRecord[]>;
151
157
  agentDirectory: hub.AgentDirectoryCapability;
@@ -1,10 +1,12 @@
1
- import { bootstrap, hub as hubNs } from '@evomap/evolver-core';
1
+ import { createHash } from 'node:crypto';
2
+ import { bootstrap, hub as hubNs, signals } from '@evomap/evolver-core';
2
3
  import { AuthError, HubFetch, HubClientError, isHubUnreachableError } from './hubFetch.js';
3
4
  import { isNodeSecret, parseNodeSecretVersion } from './auth/legacyShim.js';
4
- import { inboundToAgentEvent, agentEventToOutbound, publishRespToReceipt, searchQueryToFetchWire } from './wireMap.js';
5
+ import { inboundToAgentEvent, agentEventToOutbound, publishRespToReceipt, searchQueryToFetchWire, searchQueryToSearchOnlyWire, } from './wireMap.js';
5
6
  import { antiAbuseTelemetryMode, buildHeartbeatAntiAbuseTelemetry, } from './antiAbuseTelemetry.js';
6
7
  import { getWorkspaceKeychainMode } from './auth/workspaceKeychain.js';
7
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';
8
10
  export const INBOUND_LIMIT = 100;
9
11
  export const OUTBOUND_MAX_BATCH = 50;
10
12
  export const OUTBOUND_MAX_BODY_BYTES = 4 * 1024 * 1024;
@@ -21,13 +23,20 @@ export const USED_ASSET_ID_MAX_LEN = 200;
21
23
  export const LEARNING_ASSET_IDS_MAX = 50;
22
24
  export const LEARNING_ASSET_ID_MAX_LEN = 128;
23
25
  /** 完整 GEP-A2A 信封(实测 dev: publish/fetch/validate 等协议消息端点必须全信封, 非仅 protocol+message_type). */
24
- export function gepEnvelope(messageType, payload) {
26
+ export function gepEnvelope(messageType, payload, options = {}) {
25
27
  return {
26
28
  protocol: 'gep-a2a', protocol_version: '1.0.0', message_type: messageType,
27
- message_id: `msg_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`,
29
+ message_id: options.messageId ?? `msg_${Date.now()}_${Math.random().toString(16).slice(2, 10)}`,
28
30
  timestamp: new Date().toISOString(), payload,
29
31
  };
30
32
  }
33
+ function stablePublishMessageId(idempotencyKey) {
34
+ const digest = createHash('sha256')
35
+ .update(idempotencyKey.trim())
36
+ .digest('hex')
37
+ .slice(0, 40);
38
+ return `msg_idem_${digest}`;
39
+ }
31
40
  // v1 a2aProtocol.js L1999-2003: the three app-level rejection reasons that mean
32
41
  // our cached node_secret has DIVERGED from the hub's record (hub-side reset,
33
42
  // restored-from-backup machine, manual unlink) — not a transport/generic failure.
@@ -143,6 +152,8 @@ export class PublicHubCapability {
143
152
  ?? this.opts.senderId();
144
153
  const nodeSecret = stringField(payload, 'node_secret') ?? stringField(payload, 'nodeSecret');
145
154
  const nodeSecretVersion = parseNodeSecretVersion(payload['node_secret_version'] ?? payload['nodeSecretVersion']);
155
+ const claimCode = stringField(payload, 'claim_code') ?? stringField(payload, 'claimCode');
156
+ const claimUrl = stringField(payload, 'claim_url') ?? stringField(payload, 'claimUrl');
146
157
  if (!opts.preserveCredentials) {
147
158
  if (nodeSecret && isNodeSecret(nodeSecret)) {
148
159
  this.auth.adoptNodeSecret?.(nodeSecret, nodeSecretVersion);
@@ -154,6 +165,8 @@ export class PublicHubCapability {
154
165
  return {
155
166
  ok: payload['ok'] !== false && Boolean(nodeId),
156
167
  ...(nodeId ? { nodeId } : {}),
168
+ ...(claimCode ? { claimCode } : {}),
169
+ ...(claimUrl ? { claimUrl } : {}),
157
170
  ...(nodeSecretVersion !== undefined ? { nodeSecretVersion } : {}),
158
171
  ...(rateLimitUntilMs !== undefined ? { rateLimitUntilMs } : {}),
159
172
  ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
@@ -215,15 +228,29 @@ export class PublicHubCapability {
215
228
  }
216
229
  return Object.keys(meta).length > 0 ? meta : undefined;
217
230
  }
218
- async publish(bundle) {
231
+ async publish(bundle, options = {}) {
232
+ const normalizedIdempotencyKey = options.idempotencyKey?.trim();
233
+ if (options.idempotencyKey !== undefined && !normalizedIdempotencyKey) {
234
+ return {
235
+ receiptId: 'local_invalid_idempotency_key',
236
+ status: 'rejected',
237
+ terminal: true,
238
+ reason: 'publish idempotency key must not be blank',
239
+ };
240
+ }
219
241
  try {
220
242
  // 公版 /a2a/publish 收 payload.assets=[Gene,Capsule,(Event)] 捆绑(实测 dev).
221
- const body = await this.http.call('POST', '/a2a/publish', gepEnvelope('publish', { assets: bundle }));
243
+ const idempotencyKey = normalizedIdempotencyKey;
244
+ const messageId = idempotencyKey !== undefined
245
+ ? stablePublishMessageId(idempotencyKey)
246
+ : undefined;
247
+ const body = await this.http.call('POST', '/a2a/publish', gepEnvelope('publish', { assets: bundle }, messageId ? { messageId } : {}));
222
248
  return publishRespToReceipt(200, body);
223
249
  }
224
250
  catch (e) {
225
- if (e instanceof HubClientError)
226
- return publishRespToReceipt(e.status, e.body ?? {});
251
+ if (e instanceof HubClientError) {
252
+ return publishRespToReceipt(e.status, e.body ?? {}, e.retryAfterMs);
253
+ }
227
254
  throw e; // 5xx/网络 → 重试
228
255
  }
229
256
  }
@@ -232,29 +259,35 @@ export class PublicHubCapability {
232
259
  // /a2a/fetch responses are FULL GEP envelopes (buildResponse('fetch', …)); the rows live at payload.results,
233
260
  // NOT at the top level. Reading body.results here always yielded [] — every fetch silently returned nothing.
234
261
  const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', searchQueryToFetchWire(query)));
235
- return (body.payload?.results ?? []);
262
+ return assetsFromBody(body);
236
263
  }
237
264
  async fetchAssetById(assetId) {
238
265
  const id = assetId.trim();
239
266
  if (!id)
240
267
  return null;
241
268
  const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', { asset_ids: [id] }));
242
- return assetsFromBody(body).find((asset) => assetMatchesId(asset, id)) ?? null;
269
+ return assetsFromBody(body).find((asset) => fetchResultMatchesId(asset, id)) ?? null;
243
270
  }
244
271
  /**
245
272
  * #69: search != fetch. Free-text is the hub's vector endpoint (GET /a2a/assets/semantic-search?q=);
246
- * signals/id queries fall through to fetch. /a2a/fetch does NOT do semantic, so text must not go there.
273
+ * signal/id queries use the Hub's free search-only phase on /a2a/fetch. /a2a/fetch does NOT do semantic,
274
+ * so text must not go there and paid/full fetch must remain an explicit follow-up.
247
275
  */
248
276
  async search(query) {
249
277
  if (query.text && query.text.trim()) {
250
278
  // GET /a2a/assets/semantic-search returns a FLAT object keyed `assets` (no GEP envelope), plus a
251
- // `search_status` (found / degraded(retryable) / low_confidence_only / no_match). Reading body.results
252
- // here always yielded [] the semantic path could never return a hit. (Status not surfaced yet: the
253
- // HubCapability.search contract is AssetRecord[]; honoring search_status needs an interface change — later.)
254
- const body = await this.http.call('GET', '/a2a/assets/semantic-search', undefined, { q: query.text, ...(query.limit !== undefined ? { limit: query.limit } : {}) });
255
- return (body.assets ?? []);
279
+ // `search_status` (found / degraded(retryable) / low_confidence_only / no_match). Only an explicit
280
+ // no_match is a verified empty result; degraded or malformed 200 responses must not trigger ATP spend.
281
+ const body = await this.http.call('GET', '/a2a/assets/semantic-search', undefined, {
282
+ q: query.text,
283
+ ...(query.kind !== undefined ? { type: query.kind } : {}),
284
+ ...(query.domain !== undefined ? { domain: query.domain } : {}),
285
+ ...(query.limit !== undefined ? { limit: query.limit } : {}),
286
+ });
287
+ return semanticSearchAssets(body);
256
288
  }
257
- return this.fetch(query);
289
+ const body = await this.http.call('POST', '/a2a/fetch', gepEnvelope('fetch', searchQueryToSearchOnlyWire(query)));
290
+ return signalSearchAssets(body);
258
291
  }
259
292
  agentDirectory = {
260
293
  search: async (request) => {
@@ -603,7 +636,10 @@ export class PublicHubCapability {
603
636
  const body = await this.http.call('POST', '/a2a/events/poll', gepEnvelope('events_poll', { timeout_ms: 1000 }));
604
637
  for (const e of body.events ?? []) {
605
638
  if (String(e['type']).startsWith('task_')) {
606
- yield { taskId: String(e['payload']?.taskId ?? e['id']), type: String(e['type']), payload: e['payload'], priority: e['priority'] ?? 'medium', createdAt: Date.parse(String(e['created_at'] ?? '')) || 0 };
639
+ const payload = asRecord(e['payload']);
640
+ const wireTaskId = payload?.['task_id'] ?? payload?.['taskId'];
641
+ const taskId = typeof wireTaskId === 'string' && wireTaskId.length > 0 ? wireTaskId : String(e['id']);
642
+ yield { taskId, type: String(e['type']), payload: e['payload'], priority: e['priority'] ?? 'medium', createdAt: Date.parse(String(e['created_at'] ?? '')) || 0 };
607
643
  }
608
644
  }
609
645
  }
@@ -703,6 +739,44 @@ export class PublicHubCapability {
703
739
  function asRecord(value) {
704
740
  return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
705
741
  }
742
+ function searchAssets(value, source) {
743
+ if (!Array.isArray(value))
744
+ throw new Error(`${source}_results_invalid`);
745
+ return value.map((candidate) => {
746
+ const record = asRecord(candidate);
747
+ if (!record)
748
+ throw new Error(`${source}_asset_invalid`);
749
+ const asset = unwrapFetchDeliveryRow(record);
750
+ const assetId = stringField(asset, 'asset_id') ?? stringField(asset, 'assetId');
751
+ if (!assetId)
752
+ throw new Error(`${source}_asset_invalid`);
753
+ return asset;
754
+ });
755
+ }
756
+ function semanticSearchAssets(body) {
757
+ const status = stringField(body, 'search_status');
758
+ if (status === 'degraded' || body['retryable'] === true)
759
+ throw new Error('semantic_search_degraded');
760
+ const assets = searchAssets(body['assets'], 'semantic_search');
761
+ if (status === 'no_match') {
762
+ if (assets.length !== 0)
763
+ throw new Error('semantic_search_status_invalid');
764
+ return assets;
765
+ }
766
+ if (status === 'found' || status === 'low_confidence_only') {
767
+ if (assets.length === 0)
768
+ throw new Error('semantic_search_status_invalid');
769
+ return assets;
770
+ }
771
+ // Older successful Hub responses are usable only when they carry a concrete candidate.
772
+ if (status === undefined && assets.length > 0)
773
+ return assets;
774
+ throw new Error('semantic_search_status_invalid');
775
+ }
776
+ function signalSearchAssets(body) {
777
+ const payload = asRecord(body['payload']);
778
+ return searchAssets(payload?.['results'], 'signal_search');
779
+ }
706
780
  function recipeStepToWire(step) {
707
781
  return {
708
782
  asset_id: step.assetId,
@@ -809,7 +883,9 @@ function accountAssetsFromPayload(payload) {
809
883
  for (const candidate of candidates) {
810
884
  if (!Array.isArray(candidate))
811
885
  continue;
812
- return candidate.filter((asset) => Boolean(asset && typeof asset === 'object' && !Array.isArray(asset)));
886
+ return candidate
887
+ .filter((asset) => Boolean(asset && typeof asset === 'object' && !Array.isArray(asset)))
888
+ .map(unwrapFetchDeliveryRow);
813
889
  }
814
890
  return [];
815
891
  }
@@ -912,8 +988,10 @@ function failureReason(error) {
912
988
  }
913
989
  return error instanceof Error ? error.message : String(error);
914
990
  }
915
- function assetMatchesId(asset, assetId) {
916
- return Boolean(asset && (asset.asset_id === assetId || stringField(asset, 'id') === assetId));
991
+ function fetchResultMatchesId(asset, requestedId) {
992
+ if (assetMatchesId(asset, requestedId))
993
+ return true;
994
+ return !requestedId.startsWith('sha256:') && Boolean(asset && stringField(asset, 'id') === requestedId);
917
995
  }
918
996
  function stringField(value, key) {
919
997
  return typeof value[key] === 'string' && value[key].length > 0 ? value[key] : undefined;
@@ -987,14 +1065,39 @@ function mailboxPushResultFromBody(body, events) {
987
1065
  if (results.length === 0) {
988
1066
  return { outcomes: events.map((event) => ({ id: event.id, status: 'accepted' })) };
989
1067
  }
1068
+ const resultIds = results.map(mailboxPushResultId);
1069
+ const hasCompletePositions = results.length === events.length;
990
1070
  return {
991
- outcomes: events.map((event, index) => mailboxPushOutcomeFromRow(event.id, results, index)),
1071
+ outcomes: events.map((event, index) => {
1072
+ const matches = results.filter((_, resultIndex) => resultIds[resultIndex] === event.id);
1073
+ if (matches.length === 1)
1074
+ return mailboxPushOutcomeFromRow(event.id, matches[0]);
1075
+ const positionalMatch = matches.length === 0
1076
+ && hasCompletePositions
1077
+ && resultIds[index] === undefined
1078
+ ? results[index]
1079
+ : undefined;
1080
+ return mailboxPushOutcomeFromRow(event.id, positionalMatch);
1081
+ }),
992
1082
  };
993
1083
  }
994
- function mailboxPushOutcomeFromRow(eventId, results, index) {
995
- const match = results.find((result) => String(result['id'] ?? result['message_id'] ?? '') === eventId) ?? results[index];
996
- if (!match)
997
- return { id: eventId, status: 'accepted' };
1084
+ function mailboxPushResultId(row) {
1085
+ const value = row['id'] ?? row['message_id'];
1086
+ if (typeof value !== 'string' && typeof value !== 'number')
1087
+ return undefined;
1088
+ const id = String(value);
1089
+ return id.length > 0 ? id : undefined;
1090
+ }
1091
+ function mailboxPushOutcomeFromRow(eventId, match) {
1092
+ if (!match) {
1093
+ return {
1094
+ id: eventId,
1095
+ status: 'failed',
1096
+ reason: 'mailbox_response_incomplete',
1097
+ retryable: true,
1098
+ terminal: false,
1099
+ };
1100
+ }
998
1101
  const reason = mailboxPushFailureReason(match);
999
1102
  if (!reason)
1000
1103
  return { id: eventId, status: 'accepted', raw: match };
@@ -1047,9 +1150,12 @@ function hubErrorStatus(err) {
1047
1150
  }
1048
1151
  return undefined;
1049
1152
  }
1050
- // Retry-After hint from a HubClientError JSON body (429 cooldown). Headers aren't
1051
- // carried on HubClientError, so we read the body's retry_after_ms / retry_after.
1153
+ // Prefer the standardized Retry-After header carried by HubClientError, then
1154
+ // preserve the existing JSON body hints as a compatibility fallback.
1052
1155
  function clientErrorRetryAfterMs(err) {
1156
+ if (err instanceof HubClientError && typeof err.retryAfterMs === 'number' && Number.isFinite(err.retryAfterMs)) {
1157
+ return err.retryAfterMs;
1158
+ }
1053
1159
  const body = err instanceof HubClientError ? asRecord(err.body) : undefined;
1054
1160
  if (!body)
1055
1161
  return undefined;
@@ -1193,6 +1299,10 @@ function heartbeatResultFromBody(httpStatus, body) {
1193
1299
  const details = payload['details'] ?? body['details'];
1194
1300
  const ack = asRecord(payload['last_update_ack']);
1195
1301
  const forceUpdate = forceUpdateFromRecord(asRecord(payload['force_update']));
1302
+ const rawCapabilityGaps = payload['capability_gaps'] ?? payload['capabilityGaps'];
1303
+ const capabilityGaps = Array.isArray(rawCapabilityGaps)
1304
+ ? signals.normalizeCapabilityGaps(rawCapabilityGaps)
1305
+ : undefined;
1196
1306
  const ok = httpStatus >= 200
1197
1307
  && httpStatus < 300
1198
1308
  && payload['ok'] !== false
@@ -1210,6 +1320,7 @@ function heartbeatResultFromBody(httpStatus, body) {
1210
1320
  ...(typeof ack['reason'] === 'string' ? { reason: ack['reason'] } : {}),
1211
1321
  } } : {}),
1212
1322
  ...(forceUpdate ? { forceUpdate } : {}),
1323
+ ...(capabilityGaps !== undefined ? { capabilityGaps } : {}),
1213
1324
  };
1214
1325
  }
1215
1326
  function forceUpdateFromRecord(value) {