@mono-agent/a2a-adapter 0.11.2 → 0.11.4

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/README.md CHANGED
@@ -120,21 +120,78 @@ omits the namespace; they never silently start without the advertised guard.
120
120
 
121
121
  ## Public API
122
122
 
123
- - `startA2AProvider`
124
- - `createA2AChannelDriver`
125
- - `createChannelDriver`
126
- - `createA2AAgentCard`
127
- - `createA2AConsumer`
128
- - `dispatchA2AMessage`
129
- - `discoverA2AAgent`
130
- - `sendA2AMessage`
131
- - `createA2AConsumerResponder`
132
- - `A2A_CONFIG_FIELDS`
133
- - `loadA2AAdapterConfig`
134
- - `redactA2AAdapterConfig`
123
+ <!-- public-api-inventory:start -->
124
+ <!-- Generated by scripts/generate-public-api-docs.mjs. Do not edit by hand. -->
125
+
126
+ Every symbol exported by each public code entrypoint is listed below.
127
+
128
+ **`@mono-agent/a2a-adapter`**
129
+
130
+ ```text
131
+ A2AAdapterAgentConfig
132
+ A2AAdapterConfig
133
+ A2AAdapterConsumerConfig
134
+ A2AAdapterProviderConfig
135
+ A2AAdapterRawConfig
136
+ A2AAgentCardOptions
137
+ A2AAgentRequest
138
+ A2AAgentSkillOptions
139
+ A2AChannelDriverOptions
140
+ A2AConsumer
141
+ A2AConsumerDispatch
142
+ A2AConsumerDispatchCancelOptions
143
+ A2AConsumerDispatchMessageInput
144
+ A2AConsumerDispatchObservationOptions
145
+ A2AConsumerError
146
+ A2AConsumerErrorCode
147
+ A2AConsumerErrorDetails
148
+ A2AConsumerOptions
149
+ A2AConsumerResponderOptions
150
+ A2AConsumerResponse
151
+ A2AConsumerResponseMetadata
152
+ A2AConsumerSendMessageInput
153
+ A2AConsumerTerminalOutcome
154
+ A2AProviderError
155
+ A2AProviderErrorCode
156
+ A2AProviderErrorDetails
157
+ A2AProviderIdempotencyOptions
158
+ A2AProviderLogger
159
+ A2AProviderOptions
160
+ A2AProviderStartResult
161
+ A2ARequestMetadata
162
+ A2A_CONFIG_FIELDS
163
+ A2A_IDEMPOTENCY_EXTENSION_URI
164
+ A2A_IDEMPOTENCY_METADATA_KEY
165
+ A2A_IDEMPOTENCY_SCHEMA_VERSION
166
+ AgentCard
167
+ LoadA2AAdapterConfigInput
168
+ Message
169
+ RedactedA2AAdapterConfig
170
+ SendMessageResult
171
+ Task
172
+ createA2AAgentCard
173
+ createA2AChannelDriver
174
+ createA2AConsumer
175
+ createA2AConsumerResponder
176
+ createChannelDriver
177
+ defaultA2AIdempotencyStateDir
178
+ discoverA2AAgent
179
+ dispatchA2AMessage
180
+ loadA2AAdapterConfig
181
+ normalizeA2AIdempotencyKey
182
+ normalizeBaseUrl
183
+ redactA2AAdapterConfig
184
+ sendA2AMessage
185
+ startA2AProvider
186
+ ```
187
+
188
+ <!-- public-api-inventory:end -->
135
189
 
136
190
  Config can be loaded from a `channels.plugins[]` entry or explicit environment variables:
137
191
 
192
+ Set `MONO_AGENT_A2A_BEARER_TOKEN` in `.env` when `requireBearer` is enabled;
193
+ the source-config example omits the credential.
194
+
138
195
  ```json
139
196
  {
140
197
  "agent": {
@@ -151,7 +208,6 @@ Config can be loaded from a `channels.plugins[]` entry or explicit environment v
151
208
  "host": "127.0.0.1",
152
209
  "port": 4300,
153
210
  "requireBearer": true,
154
- "bearerToken": "redacted-value",
155
211
  "idempotency": {
156
212
  "namespace": "agent-a-production",
157
213
  "stateDir": ".mono-agent/a2a-agent-a",
@@ -17,6 +17,17 @@ interface IdempotencyEnvelope {
17
17
  readonly schemaVersion: 1;
18
18
  readonly key: string;
19
19
  }
20
+ interface FileIdempotencyStoreHooks {
21
+ /** Deterministic test seam after the admission is durable but before it is published. */
22
+ readonly beforeAdmissionPublish?: () => Promise<void>;
23
+ /** Deterministic test seam after publication but before the staging link is removed. */
24
+ readonly afterAdmissionPublish?: () => Promise<void>;
25
+ /** Deterministic test seam after opening a canonical record but before pathname revalidation. */
26
+ readonly afterRecordOpen?: (input: {
27
+ readonly keyHash: string;
28
+ readonly attempt: number;
29
+ }) => Promise<void>;
30
+ }
20
31
  export declare function createIdempotentA2ARequestHandler(input: {
21
32
  readonly delegate: A2ARequestHandler;
22
33
  readonly taskStore: TaskStore;
@@ -25,6 +36,8 @@ export declare function createIdempotentA2ARequestHandler(input: {
25
36
  warn?(message: string, metadata?: Record<string, unknown>): void;
26
37
  error?(message: string, metadata?: Record<string, unknown>): void;
27
38
  };
39
+ /** @internal Deterministic filesystem scheduling hooks for adversarial tests. */
40
+ readonly storeHooks?: FileIdempotencyStoreHooks;
28
41
  }): Promise<A2ARequestHandler>;
29
42
  export declare function validateA2AProviderIdempotencyOptions(options: A2AProviderIdempotencyOptions): void;
30
43
  export declare function guardUnsupportedA2AIdempotency(delegate: A2ARequestHandler): A2ARequestHandler;
@@ -1 +1 @@
1
- {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAwBA,OAAO,EAEL,KAAK,iBAAiB,EAEtB,KAAK,SAAS,EACf,MAAM,oBAAoB,CAAC;AAI5B,eAAO,MAAM,4BAA4B,sCAAsC,CAAC;AAChF,eAAO,MAAM,8BAA8B,IAAI,CAAC;AAChD,eAAO,MAAM,6BAA6B,yDAAyD,CAAC;AAmBpG,MAAM,WAAW,6BAA6B;IAC5C,2CAA2C;IAC3C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gFAAgF;IAChF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,yBAAyB,GACjC,oBAAoB,GACpB,UAAU,GACV,UAAU,GACV,aAAa,GACb,gBAAgB,GAChB,aAAa,CAAC;AAElB,UAAU,mBAAmB;IAC3B,QAAQ,CAAC,aAAa,EAAE,CAAC,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAqED,wBAAsB,iCAAiC,CAAC,KAAK,EAAE;IAC7D,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,CAAC;IACrC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,6BAA6B,CAAC;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE;QAChB,IAAI,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;QACjE,KAAK,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;KACnE,CAAC;CACH,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAa7B;AAED,wBAAgB,qCAAqC,CACnD,OAAO,EAAE,6BAA6B,GACrC,IAAI,CAWN;AAED,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,iBAAiB,GAC1B,iBAAiB,CAEnB;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUhE;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,CAKvE;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAIpF;AAED,wBAAgB,oCAAoC,CAAC,MAAM,EAAE,MAAM,GAAG,yBAAyB,GAAG,SAAS,CAuB1G"}
1
+ {"version":3,"file":"idempotency.d.ts","sourceRoot":"","sources":["../src/idempotency.ts"],"names":[],"mappings":"AAyBA,OAAO,EAEL,KAAK,iBAAiB,EAEtB,KAAK,SAAS,EACf,MAAM,oBAAoB,CAAC;AAI5B,eAAO,MAAM,4BAA4B,sCAAsC,CAAC;AAChF,eAAO,MAAM,8BAA8B,IAAI,CAAC;AAChD,eAAO,MAAM,6BAA6B,yDAAyD,CAAC;AAqBpG,MAAM,WAAW,6BAA6B;IAC5C,2CAA2C;IAC3C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,gFAAgF;IAChF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,MAAM,yBAAyB,GACjC,oBAAoB,GACpB,UAAU,GACV,UAAU,GACV,aAAa,GACb,gBAAgB,GAChB,aAAa,CAAC;AAElB,UAAU,mBAAmB;IAC3B,QAAQ,CAAC,aAAa,EAAE,CAAC,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAgDD,UAAU,yBAAyB;IACjC,yFAAyF;IACzF,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACtD,wFAAwF;IACxF,QAAQ,CAAC,qBAAqB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,iGAAiG;IACjG,QAAQ,CAAC,eAAe,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7G;AAuBD,wBAAsB,iCAAiC,CAAC,KAAK,EAAE;IAC7D,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,CAAC;IACrC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,6BAA6B,CAAC;IAChD,QAAQ,CAAC,MAAM,CAAC,EAAE;QAChB,IAAI,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;QACjE,KAAK,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;KACnE,CAAC;IACF,iFAAiF;IACjF,QAAQ,CAAC,UAAU,CAAC,EAAE,yBAAyB,CAAC;CACjD,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAmB7B;AAED,wBAAgB,qCAAqC,CACnD,OAAO,EAAE,6BAA6B,GACrC,IAAI,CAWN;AAED,wBAAgB,8BAA8B,CAC5C,QAAQ,EAAE,iBAAiB,GAC1B,iBAAiB,CAEnB;AAED,wBAAgB,0BAA0B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAUhE;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,mBAAmB,CAKvE;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAIpF;AAED,wBAAgB,oCAAoC,CAAC,MAAM,EAAE,MAAM,GAAG,yBAAyB,GAAG,SAAS,CAuB1G"}
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
- import { lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, } from "node:fs/promises";
3
+ import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink, } from "node:fs/promises";
4
4
  import { basename, dirname, resolve } from "node:path";
5
5
  import { TaskState, } from "@a2a-js/sdk";
6
6
  import { RequestMalformedError, } from "@a2a-js/sdk/server";
@@ -14,9 +14,11 @@ const STORE_MANIFEST_FILE = "manifest.json";
14
14
  const SLOTS_DIRECTORY = "slots";
15
15
  const RECORD_FILE_PATTERN = /^[a-f0-9]{64}\.json$/u;
16
16
  const SLOT_FILE_PATTERN = /^slot-([0-9]{1,7})\.json$/u;
17
+ const TEMPORARY_RECORD_FILE_PATTERN = /^\.[a-f0-9]{64}\.[a-f0-9-]+\.tmp$/u;
17
18
  const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
18
19
  const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,199}$/u;
19
20
  const MAX_RECORD_BYTES = 4 * 1024 * 1024;
21
+ const MAX_RECORD_READ_ATTEMPTS = 20;
20
22
  const ACTIVE_POLL_MS = 50;
21
23
  const DEFAULT_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
22
24
  const MIN_RETENTION_MS = 60_000;
@@ -29,7 +31,7 @@ export async function createIdempotentA2ARequestHandler(input) {
29
31
  const retentionMs = normalizeRetentionMs(input.options.retentionMs);
30
32
  const maxRecords = normalizeMaxRecords(input.options.maxRecords);
31
33
  const providerScope = idempotencyNamespaceHash(input.options.namespace);
32
- const store = await FileIdempotencyStore.create(input.options.stateDir.trim(), retentionMs, maxRecords, providerScope);
34
+ const store = await FileIdempotencyStore.create(input.options.stateDir.trim(), retentionMs, maxRecords, providerScope, input.storeHooks);
33
35
  return new IdempotentA2ARequestHandler(input.delegate, input.taskStore, store, providerScope, input.logger);
34
36
  }
35
37
  export function validateA2AProviderIdempotencyOptions(options) {
@@ -339,7 +341,8 @@ class FileIdempotencyStore {
339
341
  providerScope;
340
342
  slotsDir;
341
343
  slotsDirIdentity;
342
- constructor(stateDir, stateDirIdentity, retentionMs, maxRecords, providerScope, slotsDir, slotsDirIdentity) {
344
+ hooks;
345
+ constructor(stateDir, stateDirIdentity, retentionMs, maxRecords, providerScope, slotsDir, slotsDirIdentity, hooks) {
343
346
  this.stateDir = stateDir;
344
347
  this.stateDirIdentity = stateDirIdentity;
345
348
  this.retentionMs = retentionMs;
@@ -347,11 +350,12 @@ class FileIdempotencyStore {
347
350
  this.providerScope = providerScope;
348
351
  this.slotsDir = slotsDir;
349
352
  this.slotsDirIdentity = slotsDirIdentity;
353
+ this.hooks = hooks;
350
354
  }
351
- static async create(inputPath, retentionMs, maxRecords, providerScope) {
355
+ static async create(inputPath, retentionMs, maxRecords, providerScope, hooks = {}) {
352
356
  const stateDirectory = await ensurePrivateStateDir(resolve(inputPath));
353
357
  const slotsDirectory = await ensurePrivateStateDir(resolve(stateDirectory.path, SLOTS_DIRECTORY));
354
- const store = new FileIdempotencyStore(stateDirectory.path, stateDirectory.identity, retentionMs, maxRecords, providerScope, slotsDirectory.path, slotsDirectory.identity);
358
+ const store = new FileIdempotencyStore(stateDirectory.path, stateDirectory.identity, retentionMs, maxRecords, providerScope, slotsDirectory.path, slotsDirectory.identity, hooks);
355
359
  await store.ensureManifest();
356
360
  await store.pruneExpiredTerminalRecords();
357
361
  await store.reconcileOrphanSlots();
@@ -363,10 +367,10 @@ class FileIdempotencyStore {
363
367
  const path = resolve(this.stateDir, `${keyHash}.json`);
364
368
  let raw;
365
369
  try {
366
- raw = await secureReadPrivateFile(path, RECORD_FILE_PATTERN);
370
+ raw = await secureReadPublishedRecord(path, this.stateDir, keyHash, this.hooks);
367
371
  }
368
372
  catch (error) {
369
- if (isErrno(error, "ENOENT")) {
373
+ if (error instanceof RecordNotFoundError) {
370
374
  await this.assertDirectories();
371
375
  return undefined;
372
376
  }
@@ -422,40 +426,65 @@ class FileIdempotencyStore {
422
426
  const slot = await this.allocateSlot(record.keyHash);
423
427
  const admitted = { ...record, slot };
424
428
  const destination = resolve(this.stateDir, `${record.keyHash}.json`);
429
+ const temporary = resolve(this.stateDir, `.${record.keyHash}.${randomUUID()}.tmp`);
425
430
  const contents = Buffer.from(`${canonicalJson(admitted)}\n`, "utf8");
426
431
  let handle;
427
432
  try {
428
- handle = await open(destination, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
433
+ handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600);
429
434
  }
430
435
  catch (error) {
431
- if (isErrno(error, "EEXIST")) {
432
- await this.assertDirectories();
433
- return undefined;
434
- }
435
- throw storeError("Failed to create the durable A2A idempotency admission.", error);
436
+ throw storeError("Failed to stage the durable A2A idempotency admission.", error);
436
437
  }
437
438
  try {
438
439
  await handle.writeFile(contents);
439
440
  await handle.chmod(0o600);
440
441
  await handle.sync();
442
+ await handle.close();
443
+ handle = undefined;
441
444
  }
442
445
  catch (error) {
443
- await handle.close().catch(() => undefined);
444
- await unlink(destination).catch(() => undefined);
446
+ await handle?.close().catch(() => undefined);
447
+ await unlink(temporary).catch(() => undefined);
445
448
  throw storeError("Failed to persist the durable A2A idempotency admission.", error);
446
449
  }
450
+ let published = false;
447
451
  try {
448
- await handle.close();
452
+ await this.hooks.beforeAdmissionPublish?.();
453
+ await this.assertDirectories();
454
+ try {
455
+ // A hard link is the portable no-clobber publication primitive: the
456
+ // canonical name appears atomically and already references the fully
457
+ // written, fsynced inode. Concurrent publishers cannot replace it.
458
+ await link(temporary, destination);
459
+ published = true;
460
+ }
461
+ catch (error) {
462
+ if (!isErrno(error, "EEXIST")) {
463
+ throw error;
464
+ }
465
+ // Persist the shared winner's directory entry before allowing this
466
+ // process to observe and fail closed on its active admission.
467
+ await fsyncDirectory(this.stateDir, this.stateDirIdentity);
468
+ await this.assertDirectories();
469
+ return undefined;
470
+ }
471
+ await this.hooks.afterAdmissionPublish?.();
472
+ await unlink(temporary);
449
473
  await fsyncDirectory(this.stateDir, this.stateDirIdentity);
450
474
  await this.verifySlot(slot, record.keyHash);
451
475
  await this.assertDirectories();
452
476
  return admitted;
453
477
  }
454
478
  catch (error) {
455
- await handle.close().catch(() => undefined);
456
- await unlink(destination).catch(() => undefined);
457
- await fsyncDirectory(this.stateDir, this.stateDirIdentity).catch(() => undefined);
458
- throw storeError("Failed to finalize the durable A2A idempotency admission.", error);
479
+ if (published) {
480
+ // The canonical record is complete and may already be observed. Never
481
+ // remove it after publication: retaining it is the fail-closed result.
482
+ throw storeError("Failed to finalize the durable A2A idempotency admission.", error);
483
+ }
484
+ throw storeError("Failed to publish the durable A2A idempotency admission.", error);
485
+ }
486
+ finally {
487
+ await unlink(temporary).catch(() => undefined);
459
488
  }
460
489
  }
461
490
  async assertDirectories() {
@@ -639,7 +668,7 @@ class FileIdempotencyStore {
639
668
  if (!entry.isFile() || !RECORD_FILE_PATTERN.test(entry.name)) {
640
669
  // Atomic-write remnants are retained for operator inspection; they do
641
670
  // not count as admissions because capacity is held by slot files.
642
- if (/^\.[a-f0-9]{64}\.[a-f0-9-]+\.tmp$/u.test(entry.name)) {
671
+ if (TEMPORARY_RECORD_FILE_PATTERN.test(entry.name)) {
643
672
  continue;
644
673
  }
645
674
  throw storeError(`Unexpected entry in A2A idempotency stateDir: ${entry.name}`);
@@ -647,7 +676,7 @@ class FileIdempotencyStore {
647
676
  const keyHash = entry.name.slice(0, -".json".length);
648
677
  let record;
649
678
  try {
650
- record = parseRecord(JSON.parse((await secureReadPrivateFile(resolve(this.stateDir, entry.name), RECORD_FILE_PATTERN)).toString("utf8")), keyHash);
679
+ record = parseRecord(JSON.parse((await secureReadPublishedRecord(resolve(this.stateDir, entry.name), this.stateDir, keyHash, this.hooks)).toString("utf8")), keyHash);
651
680
  }
652
681
  catch (error) {
653
682
  throw storeError("A2A idempotency state is malformed during startup scan.", error);
@@ -832,9 +861,6 @@ function isTerminalState(state) {
832
861
  function cloneResult(value) {
833
862
  return revivePersistedResult(value);
834
863
  }
835
- function cloneRecord(value) {
836
- return structuredClone(value);
837
- }
838
864
  function parseRecord(value, expectedKeyHash) {
839
865
  if (!isRecord(value)) {
840
866
  throw new Error("record must be an object");
@@ -1260,6 +1286,139 @@ async function secureReadPrivateFile(path, allowedName) {
1260
1286
  await handle.close();
1261
1287
  }
1262
1288
  }
1289
+ class RecordPathChangedError extends Error {
1290
+ constructor() {
1291
+ super("A2A idempotency record pathname changed during a secure read.");
1292
+ this.name = "RecordPathChangedError";
1293
+ }
1294
+ }
1295
+ class RecordNotFoundError extends Error {
1296
+ constructor() {
1297
+ super("A2A idempotency record does not exist.");
1298
+ this.name = "RecordNotFoundError";
1299
+ }
1300
+ }
1301
+ async function secureReadPublishedRecord(path, stateDir, keyHash, hooks = {}) {
1302
+ assertKeyHash(keyHash);
1303
+ if (basename(path) !== `${keyHash}.json`) {
1304
+ throw new Error("invalid record path");
1305
+ }
1306
+ let observedCanonical = false;
1307
+ let lastPathChange;
1308
+ for (let attempt = 1; attempt <= MAX_RECORD_READ_ATTEMPTS; attempt += 1) {
1309
+ let handle;
1310
+ try {
1311
+ handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
1312
+ observedCanonical = true;
1313
+ }
1314
+ catch (error) {
1315
+ if (!isErrno(error, "ENOENT")) {
1316
+ throw error;
1317
+ }
1318
+ if (!observedCanonical) {
1319
+ throw new RecordNotFoundError();
1320
+ }
1321
+ lastPathChange = new RecordPathChangedError();
1322
+ continue;
1323
+ }
1324
+ try {
1325
+ await hooks.afterRecordOpen?.({ keyHash, attempt });
1326
+ let details = await handle.stat();
1327
+ await assertCurrentRecordPath(path, details);
1328
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
1329
+ assertSafeOpenedRecord(details, uid);
1330
+ let validatedTemporaryLink = false;
1331
+ if (details.nlink !== 1) {
1332
+ if (details.nlink !== 2) {
1333
+ throw new Error("record identity or permissions are unsafe");
1334
+ }
1335
+ const expectedPrefix = `.${keyHash}.`;
1336
+ let matchingTemporaryLinks = 0;
1337
+ for (const entry of await readdir(stateDir, { withFileTypes: true })) {
1338
+ if (!entry.name.startsWith(expectedPrefix)
1339
+ || !TEMPORARY_RECORD_FILE_PATTERN.test(entry.name)) {
1340
+ continue;
1341
+ }
1342
+ let candidate;
1343
+ try {
1344
+ candidate = await lstat(resolve(stateDir, entry.name));
1345
+ }
1346
+ catch (error) {
1347
+ if (isErrno(error, "ENOENT")) {
1348
+ continue;
1349
+ }
1350
+ throw error;
1351
+ }
1352
+ if (candidate.isFile()
1353
+ && !candidate.isSymbolicLink()
1354
+ && candidate.dev === details.dev
1355
+ && candidate.ino === details.ino
1356
+ && (candidate.mode & 0o777) === 0o600
1357
+ && (uid === undefined || candidate.uid === uid)) {
1358
+ matchingTemporaryLinks += 1;
1359
+ }
1360
+ }
1361
+ // The publisher may unlink its staging name while the directory is
1362
+ // scanned. Re-read the opened inode before deciding whether its second
1363
+ // link is the one expected from atomic publication.
1364
+ details = await handle.stat();
1365
+ assertSafeOpenedRecord(details, uid);
1366
+ if (details.nlink !== 1 && (details.nlink !== 2 || matchingTemporaryLinks !== 1)) {
1367
+ throw new Error("record identity or permissions are unsafe");
1368
+ }
1369
+ validatedTemporaryLink = details.nlink === 2;
1370
+ }
1371
+ await assertCurrentRecordPath(path, details);
1372
+ const raw = await readFile(handle);
1373
+ const finalDetails = await handle.stat();
1374
+ assertSafeOpenedRecord(finalDetails, uid);
1375
+ if (finalDetails.dev !== details.dev
1376
+ || finalDetails.ino !== details.ino
1377
+ || finalDetails.size !== raw.byteLength
1378
+ || (finalDetails.nlink !== 1 && !(validatedTemporaryLink && finalDetails.nlink === 2))) {
1379
+ throw new Error("record identity or permissions are unsafe");
1380
+ }
1381
+ await assertCurrentRecordPath(path, finalDetails);
1382
+ return raw;
1383
+ }
1384
+ catch (error) {
1385
+ if (!(error instanceof RecordPathChangedError)) {
1386
+ throw error;
1387
+ }
1388
+ lastPathChange = error;
1389
+ }
1390
+ finally {
1391
+ await handle.close();
1392
+ }
1393
+ }
1394
+ throw lastPathChange ?? new RecordPathChangedError();
1395
+ }
1396
+ function assertSafeOpenedRecord(details, uid) {
1397
+ if (!details.isFile()
1398
+ || (details.mode & 0o777) !== 0o600
1399
+ || details.size > MAX_RECORD_BYTES
1400
+ || (uid !== undefined && details.uid !== uid)) {
1401
+ throw new Error("record identity or permissions are unsafe");
1402
+ }
1403
+ }
1404
+ async function assertCurrentRecordPath(path, opened) {
1405
+ let current;
1406
+ try {
1407
+ current = await lstat(path);
1408
+ }
1409
+ catch (error) {
1410
+ if (isErrno(error, "ENOENT")) {
1411
+ throw new RecordPathChangedError();
1412
+ }
1413
+ throw error;
1414
+ }
1415
+ if (!current.isFile() || current.isSymbolicLink()) {
1416
+ throw new Error("record identity or permissions are unsafe");
1417
+ }
1418
+ if (current.dev !== opened.dev || current.ino !== opened.ino) {
1419
+ throw new RecordPathChangedError();
1420
+ }
1421
+ }
1263
1422
  async function assertReplaceableRecord(path) {
1264
1423
  try {
1265
1424
  const details = await lstat(path);