@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/LICENSE +674 -0
- package/README.md +69 -13
- package/dist/idempotency.d.ts +13 -0
- package/dist/idempotency.d.ts.map +1 -1
- package/dist/idempotency.js +184 -25
- package/dist/idempotency.js.map +1 -1
- package/package.json +8 -3
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
|
-
-
|
|
124
|
-
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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",
|
package/dist/idempotency.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/idempotency.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
370
|
+
raw = await secureReadPublishedRecord(path, this.stateDir, keyHash, this.hooks);
|
|
367
371
|
}
|
|
368
372
|
catch (error) {
|
|
369
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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
|
|
444
|
-
await unlink(
|
|
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
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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 (
|
|
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
|
|
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);
|