agent-comms 9.0.11 → 9.0.12
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -6
- package/dist/account-cli.d.ts.map +1 -1
- package/dist/account-cli.js +4 -1
- package/dist/account-cli.js.map +1 -1
- package/dist/bridges/user/web/dist/mesh-worker.js +4 -4
- package/dist/bridges/user/web/dist/sw.js +1 -1
- package/dist/cli-main.d.ts.map +1 -1
- package/dist/cli-main.js +9 -12
- package/dist/cli-main.js.map +1 -1
- package/dist/core/account-bundle.d.ts +1 -1
- package/dist/core/account-bundle.d.ts.map +1 -1
- package/dist/core/account-bundle.js +26 -19
- package/dist/core/account-bundle.js.map +1 -1
- package/dist/core/account-join.d.ts.map +1 -1
- package/dist/core/account-join.js +5 -2
- package/dist/core/account-join.js.map +1 -1
- package/dist/core/account-ledger-crypto.d.ts.map +1 -1
- package/dist/core/account-ledger-crypto.js +4 -1
- package/dist/core/account-ledger-crypto.js.map +1 -1
- package/dist/core/account-ledger-store.d.ts +3 -1
- package/dist/core/account-ledger-store.d.ts.map +1 -1
- package/dist/core/account-ledger-store.js +6 -3
- package/dist/core/account-ledger-store.js.map +1 -1
- package/dist/core/account-ledger.d.ts +51 -4
- package/dist/core/account-ledger.d.ts.map +1 -1
- package/dist/core/account-ledger.js +102 -15
- package/dist/core/account-ledger.js.map +1 -1
- package/dist/core/bridge-mesh.d.ts.map +1 -1
- package/dist/core/bridge-mesh.js +3 -0
- package/dist/core/bridge-mesh.js.map +1 -1
- package/dist/core/bridge.d.ts.map +1 -1
- package/dist/core/bridge.js +16 -4
- package/dist/core/bridge.js.map +1 -1
- package/dist/core/capability-ask.d.ts.map +1 -1
- package/dist/core/capability-ask.js +10 -2
- package/dist/core/capability-ask.js.map +1 -1
- package/dist/core/connection-code-tool.d.ts +1 -1
- package/dist/core/connection-code-tool.js +2 -2
- package/dist/core/connection-code-tool.js.map +1 -1
- package/dist/core/connection-code.d.ts +5 -5
- package/dist/core/connection-code.d.ts.map +1 -1
- package/dist/core/connection-code.js +22 -6
- package/dist/core/connection-code.js.map +1 -1
- package/dist/core/device-membership.d.ts +8 -3
- package/dist/core/device-membership.d.ts.map +1 -1
- package/dist/core/device-membership.js +4 -4
- package/dist/core/device-membership.js.map +1 -1
- package/dist/core/dm-grant-actions.d.ts.map +1 -1
- package/dist/core/dm-grant-actions.js +7 -1
- package/dist/core/dm-grant-actions.js.map +1 -1
- package/dist/core/error-reporting.d.ts.map +1 -1
- package/dist/core/error-reporting.js +5 -0
- package/dist/core/error-reporting.js.map +1 -1
- package/dist/core/gateway-trust.d.ts +1 -1
- package/dist/core/gateway-trust.d.ts.map +1 -1
- package/dist/core/gateway-trust.js +4 -4
- package/dist/core/gateway-trust.js.map +1 -1
- package/dist/core/group-proofs.d.ts.map +1 -1
- package/dist/core/group-proofs.js +2 -2
- package/dist/core/group-proofs.js.map +1 -1
- package/dist/core/identity-store.d.ts +13 -9
- package/dist/core/identity-store.d.ts.map +1 -1
- package/dist/core/identity-store.js +122 -147
- package/dist/core/identity-store.js.map +1 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/iso-timestamp.d.ts +10 -0
- package/dist/core/iso-timestamp.d.ts.map +1 -0
- package/dist/core/iso-timestamp.js +19 -0
- package/dist/core/iso-timestamp.js.map +1 -0
- package/dist/core/issuer-identity-file.d.ts +15 -0
- package/dist/core/issuer-identity-file.d.ts.map +1 -1
- package/dist/core/issuer-identity-file.js +69 -20
- package/dist/core/issuer-identity-file.js.map +1 -1
- package/dist/core/machine-identity.d.ts +1 -1
- package/dist/core/machine-identity.d.ts.map +1 -1
- package/dist/core/machine-identity.js +14 -11
- package/dist/core/machine-identity.js.map +1 -1
- package/dist/core/mesh-store.d.ts +2 -2
- package/dist/core/mesh-store.d.ts.map +1 -1
- package/dist/core/mesh-store.js.map +1 -1
- package/dist/core/peer-lifecycle.d.ts.map +1 -1
- package/dist/core/peer-lifecycle.js +8 -2
- package/dist/core/peer-lifecycle.js.map +1 -1
- package/dist/core/pid-lock-file.d.ts +27 -4
- package/dist/core/pid-lock-file.d.ts.map +1 -1
- package/dist/core/pid-lock-file.js +120 -11
- package/dist/core/pid-lock-file.js.map +1 -1
- package/dist/core/process-start.d.ts +10 -0
- package/dist/core/process-start.d.ts.map +1 -0
- package/dist/core/process-start.js +43 -0
- package/dist/core/process-start.js.map +1 -0
- package/dist/core/room-lifecycle.d.ts +3 -2
- package/dist/core/room-lifecycle.d.ts.map +1 -1
- package/dist/core/room-lifecycle.js +9 -8
- package/dist/core/room-lifecycle.js.map +1 -1
- package/dist/core/room-protocol.d.ts.map +1 -1
- package/dist/core/room-protocol.js +25 -3
- package/dist/core/room-protocol.js.map +1 -1
- package/dist/core/stale-agent-checker.d.ts.map +1 -1
- package/dist/core/stale-agent-checker.js +3 -1
- package/dist/core/stale-agent-checker.js.map +1 -1
- package/dist/core/store.d.ts +3 -0
- package/dist/core/store.d.ts.map +1 -1
- package/dist/core/store.js +42 -20
- package/dist/core/store.js.map +1 -1
- package/dist/core/tool.d.ts +5 -1
- package/dist/core/tool.d.ts.map +1 -1
- package/dist/core/tool.js +7 -1
- package/dist/core/tool.js.map +1 -1
- package/dist/core/types.d.ts +1 -1
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js +8 -3
- package/dist/core/types.js.map +1 -1
- package/dist/core/user-identity.d.ts +5 -5
- package/dist/core/user-identity.d.ts.map +1 -1
- package/dist/core/user-identity.js +59 -45
- package/dist/core/user-identity.js.map +1 -1
- package/package.json +1 -1
- package/src/account-cli.ts +4 -1
- package/src/bridges/user/web/dist/mesh-worker.js +4 -4
- package/src/bridges/user/web/dist/sw.js +1 -1
- package/src/cli-main.ts +10 -12
- package/src/core/account-bundle.ts +36 -25
- package/src/core/account-join.ts +5 -2
- package/src/core/account-ledger-crypto.ts +4 -1
- package/src/core/account-ledger-store.ts +11 -3
- package/src/core/account-ledger.ts +142 -20
- package/src/core/bridge-mesh.ts +4 -0
- package/src/core/bridge.ts +24 -4
- package/src/core/capability-ask.ts +16 -1
- package/src/core/connection-code-tool.ts +2 -2
- package/src/core/connection-code.ts +34 -8
- package/src/core/device-membership.ts +12 -8
- package/src/core/dm-grant-actions.ts +7 -1
- package/src/core/error-reporting.ts +5 -0
- package/src/core/gateway-trust.ts +4 -4
- package/src/core/group-proofs.ts +5 -2
- package/src/core/identity-store.ts +182 -191
- package/src/core/index.ts +1 -0
- package/src/core/iso-timestamp.ts +21 -0
- package/src/core/issuer-identity-file.ts +87 -23
- package/src/core/machine-identity.ts +15 -12
- package/src/core/mesh-store.ts +2 -1
- package/src/core/peer-lifecycle.ts +9 -4
- package/src/core/pid-lock-file.ts +139 -11
- package/src/core/process-start.ts +45 -0
- package/src/core/room-lifecycle.ts +12 -9
- package/src/core/room-protocol.ts +23 -3
- package/src/core/stale-agent-checker.ts +3 -1
- package/src/core/store.ts +48 -23
- package/src/core/tool.ts +14 -1
- package/src/core/types.ts +9 -3
- package/src/core/user-identity.ts +65 -51
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Mesh state stays in memory and on the wire; the only thing on disk is this local credential (plus, now, the tokens minted against it), the same trust model as an SSH key. A lock file holding a PID keeps two live bridges in one slot from sharing an identity, which would put duplicate peer IDs on the mesh; the second bridge runs with an ephemeral identity (the behaviour before persistence) instead. Bridges without a graceful shutdown hook can skip releasing the lock: a stale lock is detected by probing the recorded PID, the same way the coordinator probes for stale agents.
|
|
5
5
|
*
|
|
6
|
-
* Persisting the key material rather than a bare agent ID is what makes restarts work: delivery routing fires when agentId === peerId, and peerId is deviceIdToHex(identity.deviceId), so an ID without its key can never match the running peer. Renewal near certificate expiry
|
|
6
|
+
* Persisting the key material rather than a bare agent ID is what makes restarts work: delivery routing fires when agentId === peerId, and peerId is deviceIdToHex(identity.deviceId), so an ID without its key can never match the running peer. Renewal near certificate expiry re-certifies the existing key pair, so the device-id survives it. A slot file that exists but cannot be used is never replaced: loading it throws UnusableIdentityRecordError (issuer-identity-file.ts), because a new key would silently change the peer id and discard every token the file holds. Only a missing file starts a new identity.
|
|
7
|
+
*
|
|
8
|
+
* Several processes can write one slot file: the bridge holding the slot lock, a cc-peer front loading the same identity without that lock, and whichever of them is renewing the certificate. Every read-modify-write of the file therefore runs under a short-lived write lock of its own (the slot lock is held for the bridge's whole lifetime, so it cannot serialise them). A bridge that lost the slot lock runs under an identity that is not the file's, so the tokens it mints and holds are kept in its own memory for the life of the process and never read from or written to the file.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import * as fs from "node:fs";
|
|
@@ -13,20 +15,20 @@ import {
|
|
|
13
15
|
type DataDirectoryLocation,
|
|
14
16
|
} from "./data-directory.js";
|
|
15
17
|
import type { CapabilityToken } from "wire-mesh-core/generated/protocol";
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
certifyKeyPair,
|
|
19
|
-
getCertificateFingerprint,
|
|
20
|
-
deriveDeviceId,
|
|
21
|
-
CERTIFICATE_VALIDITY_MS,
|
|
22
|
-
} from "./identity.js";
|
|
18
|
+
import { generateIdentity } from "./identity.js";
|
|
19
|
+
import { isIsoTimestamp } from "./iso-timestamp.js";
|
|
23
20
|
import type { PeerIdentity } from "./identity.js";
|
|
24
21
|
import { writeFileAtomic } from "./atomic-file.js";
|
|
25
22
|
import {
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
loadOrCreateIssuerIdentity,
|
|
24
|
+
readIssuerRecord,
|
|
25
|
+
type StoredIssuerKey,
|
|
26
|
+
} from "./issuer-identity-file.js";
|
|
27
|
+
import {
|
|
28
|
+
liveLockHolder,
|
|
28
29
|
releaseLockFile,
|
|
29
30
|
tryAcquireLock,
|
|
31
|
+
withWriteLockAsync,
|
|
30
32
|
} from "./pid-lock-file.js";
|
|
31
33
|
|
|
32
34
|
/** A bridge's identity slot: one persisted identity per harness and cwd. */
|
|
@@ -37,15 +39,8 @@ export interface IdentitySlot extends DataDirectoryLocation {
|
|
|
37
39
|
dir?: string;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
/** Renewal margin is one twelfth of the certificate's total validity period. */
|
|
41
|
-
const RENEWAL_MARGIN_FRACTION = 12;
|
|
42
|
-
/** Renew during the final twelfth of the certificate's validity. */
|
|
43
|
-
const RENEWAL_MARGIN_MS = CERTIFICATE_VALIDITY_MS / RENEWAL_MARGIN_FRACTION;
|
|
44
|
-
|
|
45
42
|
/** A persisted identity/token/trust/ledger file holds real key material or capability tokens, so it is written owner-only. */
|
|
46
43
|
const OWNER_ONLY_RW_PERMISSIONS = 0o600;
|
|
47
|
-
/** One Int32 element's worth of bytes -- sleepSync's own shared buffer needs no more than a single slot for Atomics.wait to block on. */
|
|
48
|
-
const INT32_BYTE_LENGTH = 4;
|
|
49
44
|
|
|
50
45
|
/** A CapabilityToken (COSE_Sign1: [protected header bytes, unprotected header map, payload bytes or null, signature bytes]) with every byte-string field base64-encoded for JSON storage. Only the two named unprotected-header fields (alg, kid) are round-tripped -- every token minted by wire-mesh-core today leaves the unprotected header empty (alg/kid live in the protected header instead), so the schema's own open catchall for arbitrary extra keys is left unhandled until a real caller actually needs one preserved. */
|
|
51
46
|
export type SerializedCapabilityToken = [
|
|
@@ -55,10 +50,8 @@ export type SerializedCapabilityToken = [
|
|
|
55
50
|
string,
|
|
56
51
|
];
|
|
57
52
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
certificate: string;
|
|
61
|
-
expiresAt: string;
|
|
53
|
+
/** What a slot remembers beside its key material: the tokens it bears or has issued. */
|
|
54
|
+
interface SlotTokenRecords {
|
|
62
55
|
roomTokens?: Record<string, SerializedCapabilityToken>;
|
|
63
56
|
/** The token-id (base64) of each room:member grant this identity has itself issued to another device, keyed by "roomPath::memberDeviceHex" -- the bookkeeping a room owner needs to revoke a specific member's own grant later (kickFromRoom), distinct from roomTokens above (which holds tokens this identity BEARS, not ones it issued). */
|
|
64
57
|
issuedGrants?: Record<string, string>;
|
|
@@ -66,6 +59,8 @@ interface StoredIdentity {
|
|
|
66
59
|
groupTokens?: Record<string, SerializedCapabilityToken>;
|
|
67
60
|
}
|
|
68
61
|
|
|
62
|
+
interface StoredIdentity extends StoredIssuerKey, SlotTokenRecords {}
|
|
63
|
+
|
|
69
64
|
/** A COSE_Sign1 tuple always has exactly 4 elements: protected header, unprotected header, payload, signature. */
|
|
70
65
|
const COSE_SIGN1_TUPLE_LENGTH = 4;
|
|
71
66
|
|
|
@@ -197,26 +192,26 @@ export function oplogDirFor(slot: Readonly<IdentitySlot>): string {
|
|
|
197
192
|
return path.join(dir, base);
|
|
198
193
|
}
|
|
199
194
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
`${JSON.stringify(stored, null, 2)}\n`,
|
|
209
|
-
OWNER_ONLY_RW_PERMISSIONS,
|
|
210
|
-
);
|
|
195
|
+
/** The token records of every slot this process runs under an identity that is not the slot file's (it lost the slot lock to another live process), keyed by the slot's file. They are bound to a throw-away device-id, so they live and die with the process: neither written into the winner's file nor read from it. */
|
|
196
|
+
const ephemeralTokenRecords = new Map<string, SlotTokenRecords>();
|
|
197
|
+
|
|
198
|
+
/** Loads the identity the slot file holds, creating it when the file does not exist, renewing it in place near certificate expiry. Throws UnusableIdentityRecordError, leaving the file untouched, when the file exists but cannot be used. */
|
|
199
|
+
function loadSlotIdentity(
|
|
200
|
+
paths: Readonly<ReturnType<typeof slotPaths>>,
|
|
201
|
+
): PeerIdentity {
|
|
202
|
+
return loadOrCreateIssuerIdentity(paths.identityFile, isStoredIdentity);
|
|
211
203
|
}
|
|
212
204
|
|
|
213
205
|
/**
|
|
214
206
|
* Load the persisted identity for the slot, creating it on first use, and take the slot lock. Returns an ephemeral identity when the slot is already held by another live process.
|
|
207
|
+
*
|
|
208
|
+
* Throws UnusableIdentityRecordError when the slot's file exists but cannot be used (damaged, unreadable, or holding a key that cannot be certified again). The file is left untouched, the slot lock is released and no identity is returned: a caller must not join the mesh under a key the user did not choose, so the bridge stops with the error's message, which says what to do.
|
|
215
209
|
*/
|
|
216
210
|
export function loadOrCreateIdentity(
|
|
217
211
|
slot: Readonly<IdentitySlot>,
|
|
218
212
|
): PeerIdentity {
|
|
219
|
-
const
|
|
213
|
+
const paths = slotPaths(slot);
|
|
214
|
+
const { dir, identityFile, lockFile } = paths;
|
|
220
215
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
221
216
|
|
|
222
217
|
const claim = tryAcquireLock(lockFile);
|
|
@@ -224,81 +219,38 @@ export function loadOrCreateIdentity(
|
|
|
224
219
|
console.error(
|
|
225
220
|
`agent-comms: identity slot ${slot.harness} (${slot.cwd}) is held by live pid ${String(claim.heldBy)}; running with an ephemeral identity`,
|
|
226
221
|
);
|
|
222
|
+
ephemeralTokenRecords.set(identityFile, {});
|
|
227
223
|
return generateIdentity();
|
|
228
224
|
}
|
|
229
|
-
|
|
230
|
-
return loadStoredIdentity(identityFile) ?? createIdentity(identityFile);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Load and validate the stored key material. A well-formed record nearing certificate expiry is renewed by re-certifying its existing key pair (preserving device-id) rather than replaced -- see `certifyKeyPair`'s own doc comment for why generating a fresh key pair here would be wrong. Returns undefined only when there is no usable existing key pair to renew at all (missing, unparseable, or corrupt), which is the caller's signal to generate an entirely new identity instead.
|
|
235
|
-
*/
|
|
236
|
-
function loadStoredIdentity(identityFile: string): PeerIdentity | undefined {
|
|
237
|
-
let parsed: unknown;
|
|
238
|
-
try {
|
|
239
|
-
parsed = JSON.parse(fs.readFileSync(identityFile, "utf-8"));
|
|
240
|
-
} catch {
|
|
241
|
-
return undefined;
|
|
242
|
-
}
|
|
243
|
-
if (!isStoredIdentity(parsed)) return undefined;
|
|
244
|
-
|
|
245
|
-
const expiresAt = Date.parse(parsed.expiresAt);
|
|
246
|
-
const needsRenewal =
|
|
247
|
-
Number.isNaN(expiresAt) || Date.now() > expiresAt - RENEWAL_MARGIN_MS;
|
|
225
|
+
ephemeralTokenRecords.delete(identityFile);
|
|
248
226
|
|
|
249
227
|
try {
|
|
250
|
-
|
|
251
|
-
const renewed = certifyKeyPair(parsed.privateKey);
|
|
252
|
-
// A plain persistIdentity() call here would silently drop this slot's roomTokens/issuedGrants (it always writes a bare {privateKey, certificate, expiresAt} record) -- write through writeStoredIdentity instead so a renewal preserves everything else already on record, the same way saveRoomToken/saveIssuedRoomGrant already do for their own fields.
|
|
253
|
-
writeStoredIdentity(identityFile, {
|
|
254
|
-
...parsed,
|
|
255
|
-
privateKey: renewed.privateKey,
|
|
256
|
-
certificate: renewed.certificate,
|
|
257
|
-
expiresAt: new Date(Date.now() + CERTIFICATE_VALIDITY_MS).toISOString(),
|
|
258
|
-
});
|
|
259
|
-
return renewed;
|
|
260
|
-
}
|
|
261
|
-
return {
|
|
262
|
-
privateKey: parsed.privateKey,
|
|
263
|
-
certificate: parsed.certificate,
|
|
264
|
-
fingerprint: getCertificateFingerprint(parsed.certificate),
|
|
265
|
-
deviceId: deriveDeviceId(parsed.privateKey),
|
|
266
|
-
};
|
|
228
|
+
return loadSlotIdentity(paths);
|
|
267
229
|
} catch (err) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
271
|
-
return undefined;
|
|
230
|
+
releaseLockFile(lockFile);
|
|
231
|
+
throw err;
|
|
272
232
|
}
|
|
273
233
|
}
|
|
274
234
|
|
|
275
|
-
function createIdentity(identityFile: string): PeerIdentity {
|
|
276
|
-
const identity = generateIdentity();
|
|
277
|
-
persistIdentity(identityFile, identity);
|
|
278
|
-
return identity;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
235
|
/**
|
|
282
236
|
* Probes a slot's current lock holder without taking it -- the cc-peer front (agent-comms#157) uses this read-only check to decide whether a local Claude Code session already fronts itself (a live agent-comms bridge already holds the slot) before attaching, and to notice a session's own bridge taking the slot over later so the front can yield. Returns the live PID currently holding the slot's lock, or undefined when the slot is unlocked or its recorded holder is no longer alive.
|
|
283
237
|
*/
|
|
284
238
|
export function probeSlotOwner(
|
|
285
239
|
slot: Readonly<IdentitySlot>,
|
|
286
240
|
): number | undefined {
|
|
287
|
-
|
|
288
|
-
const heldBy = readLockPid(lockFile);
|
|
289
|
-
if (heldBy === undefined) return undefined;
|
|
290
|
-
return isPidAlive(heldBy) ? heldBy : undefined;
|
|
241
|
+
return liveLockHolder(slotPaths(slot).lockFile);
|
|
291
242
|
}
|
|
292
243
|
|
|
293
244
|
/**
|
|
294
|
-
* Loads (creating on first use) the persisted identity for a slot without taking its exclusivity lock. The cc-peer front (agent-comms#157) uses this to assume a not-yet-live session's own future identity, so the device-id
|
|
245
|
+
* Loads (creating on first use) the persisted identity for a slot without taking its exclusivity lock. Throws UnusableIdentityRecordError exactly as loadOrCreateIdentity does, and the front reports it for that session without fronting it. The cc-peer front (agent-comms#157) uses this to assume a not-yet-live session's own future identity, so the device-id, and with it every agent id, room membership, and pending delivery already addressed to it, carries over unchanged the moment that session's own bridge starts and claims the slot for real. Callers must have already confirmed via probeSlotOwner that no live bridge currently holds the slot; re-probing atomically against a concurrent acquisition isn't possible across the two separate files (identity vs lock) this store keeps, so it's the front's own periodic re-probe, not this function, that detects and reacts to a real bridge taking over afterwards.
|
|
295
246
|
*/
|
|
296
247
|
export function loadIdentityForFront(
|
|
297
248
|
slot: Readonly<IdentitySlot>,
|
|
298
249
|
): PeerIdentity {
|
|
299
|
-
const
|
|
300
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
301
|
-
|
|
250
|
+
const paths = slotPaths(slot);
|
|
251
|
+
fs.mkdirSync(paths.dir, { recursive: true, mode: 0o700 });
|
|
252
|
+
ephemeralTokenRecords.delete(paths.identityFile);
|
|
253
|
+
return loadSlotIdentity(paths);
|
|
302
254
|
}
|
|
303
255
|
|
|
304
256
|
/**
|
|
@@ -308,7 +260,7 @@ export function releaseIdentityLock(slot: Readonly<IdentitySlot>): void {
|
|
|
308
260
|
releaseLockFile(slotPaths(slot).lockFile);
|
|
309
261
|
}
|
|
310
262
|
|
|
311
|
-
/** Reads the slot's raw stored identity record, or undefined if the file is missing or unparseable.
|
|
263
|
+
/** Reads the slot's raw stored identity record, or undefined if the file is missing or unparseable. Only for reads that treat "no usable record" as "no tokens", which grants nothing; every write goes through updateTokenRecords, which refuses an unusable file instead. */
|
|
312
264
|
function readStoredIdentity(identityFile: string): StoredIdentity | undefined {
|
|
313
265
|
let parsed: unknown;
|
|
314
266
|
try {
|
|
@@ -330,38 +282,77 @@ function writeStoredIdentity(
|
|
|
330
282
|
);
|
|
331
283
|
}
|
|
332
284
|
|
|
333
|
-
// Total time to wait for a concurrent lock holder to finish creating the slot's identity file before giving up. loadOrCreateIdentity always finishes creating the file before any other process can observe its lock as taken (tryAcquireLock is the very first thing it does), so a caller that lost that race and is left holding an ephemeral identity may still reach here before the winner's own
|
|
285
|
+
// Total time to wait for a concurrent lock holder to finish creating the slot's identity file before giving up. loadOrCreateIdentity always finishes creating the file before any other process can observe its lock as taken (tryAcquireLock is the very first thing it does), so a caller that lost that race and is left holding an ephemeral identity may still reach here before the winner's own creation (a keygen plus one atomic write) has completed, 2s is generously above that real cost even on a loaded machine, while still bounded so a holder that crashed mid-creation doesn't wedge this call forever.
|
|
334
286
|
const CONCURRENT_CREATE_WAIT_MS = 2000;
|
|
335
287
|
// Poll interval while waiting -- deliberately short, since the wait is expected to resolve in low single-digit milliseconds in the overwhelmingly common case.
|
|
336
288
|
const CONCURRENT_CREATE_POLL_MS = 10;
|
|
337
289
|
|
|
338
|
-
function sleepSync(ms: number): void {
|
|
339
|
-
const view = new Int32Array(new SharedArrayBuffer(INT32_BYTE_LENGTH));
|
|
340
|
-
Atomics.wait(view, 0, 0, ms);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
290
|
/**
|
|
344
|
-
*
|
|
291
|
+
* Waits briefly for the slot's identity file to appear if it doesn't exist yet but a different live process currently holds the slot's lock, that process is, by construction, in the middle of creating it (see loadOrCreateIdentity/tryAcquireLock), so a caller racing as this slot's own loser should wait for the real winner to finish rather than fail outright. Returns once the file exists, or once the lock is no longer held by a different live process, or the wait budget runs out, the last two signal there is genuinely nothing to wait for.
|
|
345
292
|
*/
|
|
346
|
-
function
|
|
293
|
+
async function waitForConcurrentCreate(
|
|
347
294
|
identityFile: string,
|
|
348
295
|
lockFile: string,
|
|
349
|
-
):
|
|
296
|
+
): Promise<void> {
|
|
350
297
|
const deadline = Date.now() + CONCURRENT_CREATE_WAIT_MS;
|
|
351
298
|
for (;;) {
|
|
352
|
-
|
|
353
|
-
if (stored !== undefined) return stored;
|
|
299
|
+
if (fs.existsSync(identityFile)) return;
|
|
354
300
|
|
|
355
|
-
const heldBy =
|
|
301
|
+
const heldBy = liveLockHolder(lockFile);
|
|
356
302
|
const heldByDifferentLiveProcess =
|
|
357
|
-
heldBy !== undefined && heldBy !== process.pid
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
303
|
+
heldBy !== undefined && heldBy !== process.pid;
|
|
304
|
+
if (!heldByDifferentLiveProcess || Date.now() >= deadline) return;
|
|
305
|
+
|
|
306
|
+
await new Promise<void>((resolve) => {
|
|
307
|
+
setTimeout(resolve, CONCURRENT_CREATE_POLL_MS);
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The token records a slot currently holds for this process: its own in-memory records when it runs under an identity that is not the slot file's, otherwise those in the file (none when the file is missing or unusable). */
|
|
313
|
+
function readTokenRecords(identityFile: string): SlotTokenRecords {
|
|
314
|
+
return (
|
|
315
|
+
ephemeralTokenRecords.get(identityFile) ??
|
|
316
|
+
readStoredIdentity(identityFile) ??
|
|
317
|
+
{}
|
|
318
|
+
);
|
|
319
|
+
}
|
|
362
320
|
|
|
363
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Applies `change` to the slot's token records and persists the result, leaving the key material exactly as it is. The read and the write happen under the slot file's write lock, so two processes updating different tokens cannot drop each other's update. A slot running under a non-file identity keeps the change in this process's memory only.
|
|
323
|
+
*
|
|
324
|
+
* With `requireIdentity`, throws when the slot has no identity file yet (waiting briefly for a concurrent creator first); otherwise a missing file is a no-op. Either way a file that exists but cannot be used throws UnusableIdentityRecordError and is not written.
|
|
325
|
+
*/
|
|
326
|
+
async function updateTokenRecords(
|
|
327
|
+
slot: Readonly<IdentitySlot>,
|
|
328
|
+
change: (records: Readonly<SlotTokenRecords>) => SlotTokenRecords | undefined,
|
|
329
|
+
requireIdentity: boolean,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
const { identityFile, lockFile } = slotPaths(slot);
|
|
332
|
+
const ephemeral = ephemeralTokenRecords.get(identityFile);
|
|
333
|
+
if (ephemeral !== undefined) {
|
|
334
|
+
ephemeralTokenRecords.set(identityFile, {
|
|
335
|
+
...ephemeral,
|
|
336
|
+
...change(ephemeral),
|
|
337
|
+
});
|
|
338
|
+
return;
|
|
364
339
|
}
|
|
340
|
+
if (requireIdentity) await waitForConcurrentCreate(identityFile, lockFile);
|
|
341
|
+
await withWriteLockAsync(identityFile, () => {
|
|
342
|
+
const stored = readIssuerRecord(identityFile, isStoredIdentity);
|
|
343
|
+
if (stored === undefined) {
|
|
344
|
+
if (requireIdentity) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`no identity persisted for this slot yet, call loadOrCreateIdentity first (${identityFile})`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const changed = change(stored);
|
|
352
|
+
if (changed !== undefined) {
|
|
353
|
+
writeStoredIdentity(identityFile, { ...stored, ...changed });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
365
356
|
}
|
|
366
357
|
|
|
367
358
|
/**
|
|
@@ -371,8 +362,7 @@ export function loadRoomTokens(
|
|
|
371
362
|
slot: Readonly<IdentitySlot>,
|
|
372
363
|
): Record<string, CapabilityToken> {
|
|
373
364
|
const { identityFile } = slotPaths(slot);
|
|
374
|
-
const
|
|
375
|
-
const serialized = stored?.roomTokens ?? {};
|
|
365
|
+
const serialized = readTokenRecords(identityFile).roomTokens ?? {};
|
|
376
366
|
const tokens: Record<string, CapabilityToken> = {};
|
|
377
367
|
for (const [roomPath, token] of Object.entries(serialized)) {
|
|
378
368
|
tokens[roomPath] = deserializeToken(token);
|
|
@@ -381,44 +371,41 @@ export function loadRoomTokens(
|
|
|
381
371
|
}
|
|
382
372
|
|
|
383
373
|
/**
|
|
384
|
-
* Persists a capability token for the given room path, surviving a restart the same way the identity it was minted against does. Overwrites any token already saved for that room; leaves every other room's token and the identity's own key material untouched.
|
|
374
|
+
* Persists a capability token for the given room path, surviving a restart the same way the identity it was minted against does. Overwrites any token already saved for that room; leaves every other room's token and the identity's own key material untouched. A slot running under an ephemeral identity keeps it in this process's memory only.
|
|
385
375
|
*/
|
|
386
|
-
export function saveRoomToken(
|
|
376
|
+
export async function saveRoomToken(
|
|
387
377
|
slot: Readonly<IdentitySlot>,
|
|
388
378
|
roomPath: string,
|
|
389
379
|
token: CapabilityToken,
|
|
390
|
-
): void {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
380
|
+
): Promise<void> {
|
|
381
|
+
await updateTokenRecords(
|
|
382
|
+
slot,
|
|
383
|
+
(records) => ({
|
|
384
|
+
roomTokens: { ...records.roomTokens, [roomPath]: serializeToken(token) },
|
|
385
|
+
}),
|
|
386
|
+
true,
|
|
395
387
|
);
|
|
396
|
-
if (stored === undefined) {
|
|
397
|
-
throw new Error(
|
|
398
|
-
`no identity persisted for this slot yet -- call loadOrCreateIdentity first (${identityFile})`,
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
const roomTokens = {
|
|
402
|
-
...stored.roomTokens,
|
|
403
|
-
[roomPath]: serializeToken(token),
|
|
404
|
-
};
|
|
405
|
-
writeStoredIdentity(identityFile, { ...stored, roomTokens });
|
|
406
388
|
}
|
|
407
389
|
|
|
408
390
|
/** Removes the persisted token for one room path, if any. A no-op if none was saved for that path. */
|
|
409
|
-
export function deleteRoomToken(
|
|
391
|
+
export async function deleteRoomToken(
|
|
410
392
|
slot: Readonly<IdentitySlot>,
|
|
411
393
|
roomPath: string,
|
|
412
|
-
): void {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
await updateTokenRecords(
|
|
396
|
+
slot,
|
|
397
|
+
(records) =>
|
|
398
|
+
records.roomTokens === undefined
|
|
399
|
+
? undefined
|
|
400
|
+
: {
|
|
401
|
+
roomTokens: Object.fromEntries(
|
|
402
|
+
Object.entries(records.roomTokens).filter(
|
|
403
|
+
([storedRoomPath]) => storedRoomPath !== roomPath,
|
|
404
|
+
),
|
|
405
|
+
),
|
|
406
|
+
},
|
|
407
|
+
false,
|
|
420
408
|
);
|
|
421
|
-
writeStoredIdentity(identityFile, { ...stored, roomTokens });
|
|
422
409
|
}
|
|
423
410
|
|
|
424
411
|
/**
|
|
@@ -428,8 +415,7 @@ export function loadGroupTokens(
|
|
|
428
415
|
slot: Readonly<IdentitySlot>,
|
|
429
416
|
): Record<string, CapabilityToken> {
|
|
430
417
|
const { identityFile } = slotPaths(slot);
|
|
431
|
-
const
|
|
432
|
-
const serialized = stored?.groupTokens ?? {};
|
|
418
|
+
const serialized = readTokenRecords(identityFile).groupTokens ?? {};
|
|
433
419
|
const tokens: Record<string, CapabilityToken> = {};
|
|
434
420
|
for (const [groupPath, token] of Object.entries(serialized)) {
|
|
435
421
|
tokens[groupPath] = deserializeToken(token);
|
|
@@ -440,42 +426,42 @@ export function loadGroupTokens(
|
|
|
440
426
|
/**
|
|
441
427
|
* Persists this device's own group:member token for the given group path, surviving a restart the same way the identity it was minted against does. Overwrites any token already saved for that group path; leaves every other group's token and the identity's own key material untouched. The device-membership counterpart of saveRoomToken.
|
|
442
428
|
*/
|
|
443
|
-
export function saveGroupToken(
|
|
429
|
+
export async function saveGroupToken(
|
|
444
430
|
slot: Readonly<IdentitySlot>,
|
|
445
431
|
groupPath: string,
|
|
446
432
|
token: CapabilityToken,
|
|
447
|
-
): void {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
433
|
+
): Promise<void> {
|
|
434
|
+
await updateTokenRecords(
|
|
435
|
+
slot,
|
|
436
|
+
(records) => ({
|
|
437
|
+
groupTokens: {
|
|
438
|
+
...records.groupTokens,
|
|
439
|
+
[groupPath]: serializeToken(token),
|
|
440
|
+
},
|
|
441
|
+
}),
|
|
442
|
+
true,
|
|
452
443
|
);
|
|
453
|
-
if (stored === undefined) {
|
|
454
|
-
throw new Error(
|
|
455
|
-
`no identity persisted for this slot yet -- call loadOrCreateIdentity first (${identityFile})`,
|
|
456
|
-
);
|
|
457
|
-
}
|
|
458
|
-
const groupTokens = {
|
|
459
|
-
...stored.groupTokens,
|
|
460
|
-
[groupPath]: serializeToken(token),
|
|
461
|
-
};
|
|
462
|
-
writeStoredIdentity(identityFile, { ...stored, groupTokens });
|
|
463
444
|
}
|
|
464
445
|
|
|
465
446
|
/** Removes the persisted token for one group path, if any. A no-op if none was saved for that path. The device-membership counterpart of deleteRoomToken. */
|
|
466
|
-
export function deleteGroupToken(
|
|
447
|
+
export async function deleteGroupToken(
|
|
467
448
|
slot: Readonly<IdentitySlot>,
|
|
468
449
|
groupPath: string,
|
|
469
|
-
): void {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
450
|
+
): Promise<void> {
|
|
451
|
+
await updateTokenRecords(
|
|
452
|
+
slot,
|
|
453
|
+
(records) =>
|
|
454
|
+
records.groupTokens === undefined
|
|
455
|
+
? undefined
|
|
456
|
+
: {
|
|
457
|
+
groupTokens: Object.fromEntries(
|
|
458
|
+
Object.entries(records.groupTokens).filter(
|
|
459
|
+
([storedGroupPath]) => storedGroupPath !== groupPath,
|
|
460
|
+
),
|
|
461
|
+
),
|
|
462
|
+
},
|
|
463
|
+
false,
|
|
477
464
|
);
|
|
478
|
-
writeStoredIdentity(identityFile, { ...stored, groupTokens });
|
|
479
465
|
}
|
|
480
466
|
|
|
481
467
|
function issuedGrantKey(roomPath: string, memberDeviceHex: string): string {
|
|
@@ -491,9 +477,10 @@ export function loadIssuedRoomGrant(
|
|
|
491
477
|
memberDeviceHex: string,
|
|
492
478
|
): Uint8Array<ArrayBuffer> | undefined {
|
|
493
479
|
const { identityFile } = slotPaths(slot);
|
|
494
|
-
const stored = readStoredIdentity(identityFile);
|
|
495
480
|
const encoded =
|
|
496
|
-
|
|
481
|
+
readTokenRecords(identityFile).issuedGrants?.[
|
|
482
|
+
issuedGrantKey(roomPath, memberDeviceHex)
|
|
483
|
+
];
|
|
497
484
|
return encoded === undefined
|
|
498
485
|
? undefined
|
|
499
486
|
: Uint8Array.from(Buffer.from(encoded, "base64"));
|
|
@@ -502,44 +489,44 @@ export function loadIssuedRoomGrant(
|
|
|
502
489
|
/**
|
|
503
490
|
* Records the token-id of a room:member grant this identity has just minted for memberDeviceHex in roomPath, surviving a restart the same way roomTokens does. Overwrites any earlier record for the same (roomPath, member) pair -- a fresh join/invite always supersedes the grant it replaces, so only the current token-id is ever worth revoking.
|
|
504
491
|
*/
|
|
505
|
-
export function saveIssuedRoomGrant(
|
|
492
|
+
export async function saveIssuedRoomGrant(
|
|
506
493
|
slot: Readonly<IdentitySlot>,
|
|
507
494
|
roomPath: string,
|
|
508
495
|
memberDeviceHex: string,
|
|
509
496
|
tokenId: Uint8Array,
|
|
510
|
-
): void {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
497
|
+
): Promise<void> {
|
|
498
|
+
await updateTokenRecords(
|
|
499
|
+
slot,
|
|
500
|
+
(records) => ({
|
|
501
|
+
issuedGrants: {
|
|
502
|
+
...records.issuedGrants,
|
|
503
|
+
[issuedGrantKey(roomPath, memberDeviceHex)]:
|
|
504
|
+
Buffer.from(tokenId).toString("base64"),
|
|
505
|
+
},
|
|
506
|
+
}),
|
|
507
|
+
true,
|
|
515
508
|
);
|
|
516
|
-
if (stored === undefined) {
|
|
517
|
-
throw new Error(
|
|
518
|
-
`no identity persisted for this slot yet -- call loadOrCreateIdentity first (${identityFile})`,
|
|
519
|
-
);
|
|
520
|
-
}
|
|
521
|
-
const issuedGrants = {
|
|
522
|
-
...stored.issuedGrants,
|
|
523
|
-
[issuedGrantKey(roomPath, memberDeviceHex)]:
|
|
524
|
-
Buffer.from(tokenId).toString("base64"),
|
|
525
|
-
};
|
|
526
|
-
writeStoredIdentity(identityFile, { ...stored, issuedGrants });
|
|
527
509
|
}
|
|
528
510
|
|
|
529
511
|
/** Removes the recorded token-id for one (roomPath, member) grant, if any -- called once a kick has revoked it, so a later re-join mints and records a genuinely fresh one rather than leaving a stale entry alongside it. A no-op if none was recorded. */
|
|
530
|
-
export function deleteIssuedRoomGrant(
|
|
512
|
+
export async function deleteIssuedRoomGrant(
|
|
531
513
|
slot: Readonly<IdentitySlot>,
|
|
532
514
|
roomPath: string,
|
|
533
515
|
memberDeviceHex: string,
|
|
534
|
-
): void {
|
|
535
|
-
const { identityFile } = slotPaths(slot);
|
|
536
|
-
const stored = readStoredIdentity(identityFile);
|
|
537
|
-
if (stored?.issuedGrants === undefined) return;
|
|
516
|
+
): Promise<void> {
|
|
538
517
|
const key = issuedGrantKey(roomPath, memberDeviceHex);
|
|
539
|
-
|
|
540
|
-
|
|
518
|
+
await updateTokenRecords(
|
|
519
|
+
slot,
|
|
520
|
+
(records) =>
|
|
521
|
+
records.issuedGrants === undefined
|
|
522
|
+
? undefined
|
|
523
|
+
: {
|
|
524
|
+
issuedGrants: Object.fromEntries(
|
|
525
|
+
Object.entries(records.issuedGrants).filter(([k]) => k !== key),
|
|
526
|
+
),
|
|
527
|
+
},
|
|
528
|
+
false,
|
|
541
529
|
);
|
|
542
|
-
writeStoredIdentity(identityFile, { ...stored, issuedGrants });
|
|
543
530
|
}
|
|
544
531
|
|
|
545
532
|
/** The trusted-gateway allowlist lives in one JSON file per identity directory, not per bridge slot: who may see and reach this user's agents from another machine is an operator decision about the machine, and every store on it, including each session the default cc-peer front handles, advertises itself under it. It sits beside the identity files rather than inside one, because the trusted set has no dependency on any slot's key material and GatewayTrust can be constructed before an identity is ever loaded. */
|
|
@@ -655,6 +642,8 @@ function isStoredConnectionCode(value: unknown): value is StoredConnectionCode {
|
|
|
655
642
|
if (!("expiresAt" in value) || !("deviceId" in value)) return false;
|
|
656
643
|
if (typeof value.expiresAt !== "string" || typeof value.deviceId !== "string")
|
|
657
644
|
return false;
|
|
645
|
+
// A record whose expiry names no instant could never be pruned or honoured, so it is not loaded.
|
|
646
|
+
if (!isIsoTimestamp(value.expiresAt)) return false;
|
|
658
647
|
if ("signature" in value && typeof value.signature !== "string") return false;
|
|
659
648
|
return true;
|
|
660
649
|
}
|
|
@@ -693,7 +682,9 @@ export function loadConnectionCodeLedger(
|
|
|
693
682
|
}
|
|
694
683
|
const redeemed: Record<string, string> = {};
|
|
695
684
|
for (const [nonce, expiresAt] of Object.entries(redeemedRaw)) {
|
|
696
|
-
if (typeof expiresAt === "string"
|
|
685
|
+
if (typeof expiresAt === "string" && isIsoTimestamp(expiresAt)) {
|
|
686
|
+
redeemed[nonce] = expiresAt;
|
|
687
|
+
}
|
|
697
688
|
}
|
|
698
689
|
return { issued, redeemed };
|
|
699
690
|
}
|
package/src/core/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export {
|
|
|
33
33
|
releaseIdentityLock,
|
|
34
34
|
} from "./identity-store.js";
|
|
35
35
|
export type { IdentitySlot } from "./identity-store.js";
|
|
36
|
+
export { UnusableIdentityRecordError } from "./issuer-identity-file.js";
|
|
36
37
|
export type { CommsContext, CommsResult } from "./tool.js";
|
|
37
38
|
export {
|
|
38
39
|
buildAction,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timestamps that arrive as text and decide whether something has expired. A text that does not parse must never read as "not yet expired", which is what comparing a failed parse (NaN) against the clock does, so every expiry in text form goes through this one parser and a caller treats `undefined` as invalid.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** The shape every timestamp this package writes has (`Date.prototype.toISOString`), and the offset form of ISO 8601 and RFC 3339 beside it: a date, `T`, a time with an optional fraction, then `Z` or a numeric offset. Anything looser (a bare year, a month name, a locale string) is parsed differently by different engines, so it is refused rather than guessed at. */
|
|
6
|
+
const ISO_TIMESTAMP =
|
|
7
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The instant `text` names, in milliseconds since the epoch, or undefined when `text` is not an ISO 8601 timestamp in the form above or names no real instant (a month 13, say).
|
|
11
|
+
*/
|
|
12
|
+
export function parseIsoTimestampMs(text: string): number | undefined {
|
|
13
|
+
if (!ISO_TIMESTAMP.test(text)) return undefined;
|
|
14
|
+
const ms = Date.parse(text);
|
|
15
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether `text` is a timestamp parseIsoTimestampMs accepts. */
|
|
19
|
+
export function isIsoTimestamp(text: string): boolean {
|
|
20
|
+
return parseIsoTimestampMs(text) !== undefined;
|
|
21
|
+
}
|