agent-comms 8.15.8 → 8.15.10
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/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +44 -24
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
- package/dist/bridges/user/web/web-assets.d.ts +28 -0
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
- package/dist/bridges/user/web/web-assets.js +67 -0
- package/dist/bridges/user/web/web-assets.js.map +1 -0
- package/package.json +8 -2
- package/src/account-cli.ts +195 -0
- package/src/account-join-cli.ts +191 -0
- package/src/bridges/cc-peer/approval-commands.ts +82 -0
- package/src/bridges/cc-peer/bridge.ts +138 -0
- package/src/bridges/cc-peer/default-front.ts +58 -0
- package/src/bridges/cc-peer/front-controller.ts +160 -0
- package/src/bridges/cc-peer/front-relay.ts +191 -0
- package/src/bridges/cc-peer/front-runtime.ts +167 -0
- package/src/bridges/cc-peer/front.ts +59 -0
- package/src/bridges/cc-peer/reply-aliases.ts +90 -0
- package/src/bridges/cc-peer/run.ts +100 -0
- package/src/bridges/cc-peer/shared-peer.ts +28 -0
- package/src/bridges/claude-code/channel.ts +318 -0
- package/src/bridges/codex/tool.ts +132 -0
- package/src/bridges/mcp/server.ts +137 -0
- package/src/bridges/opencode/plugin.ts +130 -0
- package/src/bridges/pi/extension.ts +330 -0
- package/src/bridges/registry.ts +17 -0
- package/src/bridges/user/cli.ts +87 -0
- package/src/bridges/user/controller.ts +345 -0
- package/src/bridges/user/tui.ts +300 -0
- package/src/bridges/user/web/actions.ts +173 -0
- package/src/bridges/user/web/contract.ts +222 -0
- package/src/bridges/user/web/dist/bundle.js +49 -0
- package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
- package/src/bridges/user/web/dist/index.html +21 -0
- package/src/bridges/user/web/dist/manifest.json +26 -0
- package/src/bridges/user/web/dist/mesh-worker.js +64 -0
- package/src/bridges/user/web/dist/sw.js +1 -0
- package/src/bridges/user/web/event-publisher.ts +114 -0
- package/src/bridges/user/web/port-discovery.ts +49 -0
- package/src/bridges/user/web/reads.ts +35 -0
- package/src/bridges/user/web/router.ts +158 -0
- package/src/bridges/user/web/server.ts +782 -0
- package/src/bridges/user/web/vite.config.ts +71 -0
- package/src/bridges/user/web/web-access.ts +404 -0
- package/src/bridges/user/web/web-assets.ts +90 -0
- package/src/bridges/user/web/web-beacon.ts +69 -0
- package/src/bridges/user/web/web-console-static.ts +90 -0
- package/src/cli-main.ts +635 -0
- package/src/cli.ts +13 -0
- package/src/core/account-bundle.ts +264 -0
- package/src/core/account-join.ts +289 -0
- package/src/core/account-ledger-crypto.ts +154 -0
- package/src/core/account-ledger-store.ts +66 -0
- package/src/core/account-ledger.ts +385 -0
- package/src/core/agent-details.ts +216 -0
- package/src/core/agent-list-format.ts +97 -0
- package/src/core/agent-registry.ts +282 -0
- package/src/core/agent-table.ts +58 -0
- package/src/core/atomic-file.ts +46 -0
- package/src/core/bind-retry.ts +38 -0
- package/src/core/bridge-mesh.ts +176 -0
- package/src/core/bridge.ts +718 -0
- package/src/core/capability-ask.ts +162 -0
- package/src/core/capability-bubble-up.ts +237 -0
- package/src/core/comms-store.ts +130 -0
- package/src/core/connection-approval.ts +134 -0
- package/src/core/connection-code-tool.ts +92 -0
- package/src/core/connection-code.ts +296 -0
- package/src/core/coordinator-role.ts +224 -0
- package/src/core/data-directory.ts +32 -0
- package/src/core/data-frame-routing.ts +309 -0
- package/src/core/default-command.ts +12 -0
- package/src/core/delegation-policy.ts +41 -0
- package/src/core/delivery-engine.ts +649 -0
- package/src/core/device-membership-verification.ts +125 -0
- package/src/core/device-membership.ts +130 -0
- package/src/core/directory-admission.ts +155 -0
- package/src/core/discovery-mdns.ts +247 -0
- package/src/core/discovery-tailscale.ts +224 -0
- package/src/core/discovery.ts +303 -0
- package/src/core/display-name.ts +73 -0
- package/src/core/dm-grant-actions.ts +72 -0
- package/src/core/dm-send-delegation.ts +73 -0
- package/src/core/dm-token-verification.ts +76 -0
- package/src/core/election-sessions.ts +135 -0
- package/src/core/error-reporting.ts +85 -0
- package/src/core/first-contact.ts +242 -0
- package/src/core/gateway-trust-actions.ts +184 -0
- package/src/core/gateway-trust.ts +305 -0
- package/src/core/gossip-directory.ts +276 -0
- package/src/core/gossip-extensions.ts +123 -0
- package/src/core/group-proofs.ts +205 -0
- package/src/core/handshake.ts +354 -0
- package/src/core/hub-forwarding.ts +24 -0
- package/src/core/hub-link.ts +176 -0
- package/src/core/hub-session.ts +374 -0
- package/src/core/identity-store.ts +715 -0
- package/src/core/identity.ts +489 -0
- package/src/core/index.ts +65 -0
- package/src/core/issuer-grouping.ts +165 -0
- package/src/core/issuer-identity-file.ts +187 -0
- package/src/core/lan-web-actions.ts +34 -0
- package/src/core/lan-web-uis.ts +55 -0
- package/src/core/ledger-lock.ts +81 -0
- package/src/core/listener-registry.ts +133 -0
- package/src/core/loopback-address.ts +22 -0
- package/src/core/machine-identity.ts +77 -0
- package/src/core/machine-list-groups.ts +53 -0
- package/src/core/mcp-registry-retry.ts +69 -0
- package/src/core/mcp-registry-version.ts +18 -0
- package/src/core/membership-proof.ts +139 -0
- package/src/core/membership-proofs.ts +149 -0
- package/src/core/mesh-graph-trace-tool.ts +81 -0
- package/src/core/mesh-graph.ts +89 -0
- package/src/core/mesh-network-actions.ts +183 -0
- package/src/core/mesh-store-options.ts +23 -0
- package/src/core/mesh-store-shared.ts +134 -0
- package/src/core/mesh-store.ts +1114 -0
- package/src/core/name-claim.ts +145 -0
- package/src/core/naming-actions.ts +90 -0
- package/src/core/naming.ts +138 -0
- package/src/core/nanoid.ts +20 -0
- package/src/core/network-interfaces.ts +24 -0
- package/src/core/package-version.ts +34 -0
- package/src/core/peer-lifecycle.ts +377 -0
- package/src/core/peer-link-error.ts +41 -0
- package/src/core/peer-versions.ts +98 -0
- package/src/core/pending-connection.ts +53 -0
- package/src/core/petnames.ts +121 -0
- package/src/core/pgp-keyserver.ts +32 -0
- package/src/core/pid-lock-file.ts +122 -0
- package/src/core/push-manager.ts +76 -0
- package/src/core/random-id.ts +9 -0
- package/src/core/receiver-set.ts +42 -0
- package/src/core/relay-role.ts +182 -0
- package/src/core/relay-selection.ts +120 -0
- package/src/core/relay-server.ts +198 -0
- package/src/core/relay-uplink.ts +78 -0
- package/src/core/request-timeouts.ts +37 -0
- package/src/core/room-discovery.ts +48 -0
- package/src/core/room-lifecycle.ts +838 -0
- package/src/core/room-lookup.ts +41 -0
- package/src/core/room-messaging.ts +247 -0
- package/src/core/room-notice-log.ts +61 -0
- package/src/core/room-path.ts +84 -0
- package/src/core/room-protocol.ts +893 -0
- package/src/core/room-router.ts +180 -0
- package/src/core/room-token-verification.ts +85 -0
- package/src/core/room-wire-extensions.ts +60 -0
- package/src/core/self-name-claim.ts +67 -0
- package/src/core/send-outcome.ts +69 -0
- package/src/core/shutdown-signals.ts +57 -0
- package/src/core/stale-agent-checker.ts +108 -0
- package/src/core/store.ts +704 -0
- package/src/core/token-text.ts +40 -0
- package/src/core/tool.ts +853 -0
- package/src/core/transport-events.ts +73 -0
- package/src/core/transport-queries.ts +54 -0
- package/src/core/transport.ts +367 -0
- package/src/core/types.ts +550 -0
- package/src/core/user-identity.ts +290 -0
- package/src/core/vapid.ts +81 -0
- package/src/core/version-check.ts +147 -0
- package/src/core/version-report-actions.ts +79 -0
- package/src/core/web-beacon.ts +31 -0
- package/src/core/web-push.ts +322 -0
- package/src/core/wire-mesh-core-version.ts +25 -0
- package/src/core/wire-mesh-identity.ts +36 -0
- package/src/core/wire-mesh-transport-options.ts +82 -0
- package/src/core/wire-mesh-transport.ts +1007 -0
- package/src/core/wire-protocol.ts +153 -0
- package/src/core/ws-dial.ts +191 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommsTool's gateway_generate_connection_code/gateway_redeem_connection_code handlers (agent-comms#188), extracted out of tool.ts's own class body purely to keep that file under its own line-count budget -- these two handlers have no dependency on CommsTool's other state (agentId/harness/cwd context every other handler takes), so free functions taking the store and action directly are a cleaner shape than private methods would have been anyway.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CommsResult, MeshOnlyFeatures } from "./tool.js";
|
|
6
|
+
import { tryMeshAction } from "./tool.js";
|
|
7
|
+
import type { CommsAction, ConnectionCode } from "./types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generates a fresh connection code vouching for this store's own device-id, optionally PGP-signed with a caller-supplied private key. See ConnectionCodeLedger.generate's own doc comment for what each option means.
|
|
11
|
+
*/
|
|
12
|
+
export async function handleGatewayGenerateConnectionCode(
|
|
13
|
+
store: Readonly<MeshOnlyFeatures>,
|
|
14
|
+
action: CommsAction & { action: "gateway_generate_connection_code" },
|
|
15
|
+
): Promise<CommsResult> {
|
|
16
|
+
if (!store.generateConnectionCode) {
|
|
17
|
+
return {
|
|
18
|
+
content: "Connection codes are not available on this store.",
|
|
19
|
+
isError: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const generateConnectionCode = store.generateConnectionCode.bind(store);
|
|
23
|
+
return tryMeshAction("generate connection code", async () => {
|
|
24
|
+
const code = await generateConnectionCode({
|
|
25
|
+
...(action.ttlMs !== undefined && { ttlMs: action.ttlMs }),
|
|
26
|
+
...(action.privateKey !== undefined && {
|
|
27
|
+
privateKeyArmored: action.privateKey,
|
|
28
|
+
}),
|
|
29
|
+
...(action.passphrase !== undefined && {
|
|
30
|
+
passphrase: action.passphrase,
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
const signedNote =
|
|
34
|
+
code.signature !== undefined ? " (PGP-signed)" : " (unsigned)";
|
|
35
|
+
return `Connection code generated${signedNote}, valid until ${code.expiresAt}. Share it with the counterpart out of band; it can be redeemed once:\n${JSON.stringify(code)}`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Validates a candidate connection code (assembled from action's own code/expiresAt/device/signature fields) and, on success, trusts the device-id it vouches for. When the candidate carries a signature but action supplies a fingerprint with no publicKey, fetches the signer's public key from a keyserver first via fetchPgpPublicKeyByFingerprintImpl -- injected rather than imported directly so a test can supply a fake resolver instead of making a real network call.
|
|
41
|
+
*/
|
|
42
|
+
export async function handleGatewayRedeemConnectionCode(
|
|
43
|
+
store: Readonly<MeshOnlyFeatures>,
|
|
44
|
+
action: CommsAction & { action: "gateway_redeem_connection_code" },
|
|
45
|
+
fetchPgpPublicKeyByFingerprintImpl: (fingerprint: string) => Promise<string>,
|
|
46
|
+
): Promise<CommsResult> {
|
|
47
|
+
if (!store.redeemConnectionCode) {
|
|
48
|
+
return {
|
|
49
|
+
content: "Connection codes are not available on this store.",
|
|
50
|
+
isError: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const candidate: ConnectionCode = {
|
|
54
|
+
code: action.code,
|
|
55
|
+
expiresAt: action.expiresAt,
|
|
56
|
+
deviceId: action.device,
|
|
57
|
+
...(action.signature !== undefined && { signature: action.signature }),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let publicKeyArmored = action.publicKey;
|
|
61
|
+
if (
|
|
62
|
+
candidate.signature !== undefined &&
|
|
63
|
+
publicKeyArmored === undefined &&
|
|
64
|
+
action.fingerprint !== undefined
|
|
65
|
+
) {
|
|
66
|
+
try {
|
|
67
|
+
publicKeyArmored = await fetchPgpPublicKeyByFingerprintImpl(
|
|
68
|
+
action.fingerprint,
|
|
69
|
+
);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return {
|
|
72
|
+
content: `Failed to fetch PGP public key for fingerprint ${action.fingerprint}: ${err instanceof Error ? err.message : String(err)}`,
|
|
73
|
+
isError: true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const redeemConnectionCode = store.redeemConnectionCode.bind(store);
|
|
79
|
+
return tryMeshAction("redeem connection code", async () => {
|
|
80
|
+
const result = await redeemConnectionCode(candidate, {
|
|
81
|
+
...(publicKeyArmored !== undefined && { publicKeyArmored }),
|
|
82
|
+
...(action.fingerprint !== undefined && {
|
|
83
|
+
expectedFingerprint: action.fingerprint,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const fingerprintNote =
|
|
87
|
+
result.fingerprint !== undefined
|
|
88
|
+
? ` (signature verified, signing key fingerprint ${result.fingerprint})`
|
|
89
|
+
: " (no signature; freshness only)";
|
|
90
|
+
return `Trusted remote gateway device ${result.deviceId} via connection code${fingerprintNote}.`;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ConnectionCode generation and redemption (agent-comms#188): bootstraps GatewayTrust between two devices that have never established a mesh connection, since GatewayTrust's own deny-by-default, pin-the-key model otherwise requires a device-id to already be known and pasted in by hand (gateway_trust). One artifact, one generation flow, one redemption flow -- never two mechanisms bolted together.
|
|
3
|
+
*
|
|
4
|
+
* `code`/`expiresAt` are the always-checked half: freshness/liveness proof that whoever redeems this was on the other end of this exact exchange, recently. `signature` is the optional half: a detached PGP signature (armored) over `${code}:${expiresAt}:${deviceId}`, checked only when present. Redemption never requires a signature -- a device with no PGP identity still generates and redeems a bare code -- but when one is present, verifying it needs the signer's own public key, supplied by the caller either as a pasted armored block or (see pgp-keyserver.ts) fetched from a keyserver by fingerprint. Either way, a caller who also supplies the fingerprint they already independently trust gets it pinned: the verified key's own computed fingerprint must equal it, or redemption fails.
|
|
5
|
+
*
|
|
6
|
+
* PGP key material is never generated, stored, or managed by this module or by agent-comms generally -- signing and verification both take key material supplied per call by the caller, exactly as invoking `gpg --sign`/`gpg --verify` directly would. Building a persisted PGP-identity subsystem (key generation, passphrase storage, revocation) is a materially larger, security-sensitive scope the settled design (agent-comms#188) never asked for; a caller who wants a code signed brings whatever real-world PGP identity they already have (git-commit signing key, personal key on a keyserver, etc.) rather than trusting agent-comms to mint and hold a new one on their behalf.
|
|
7
|
+
*
|
|
8
|
+
* Persistence (ConnectionCodeLedger, backed by identity-store.ts's connection-code ledger) is two independent halves, not a request/response pair: the generating device's own issued-code record survives a restart between generation and hand-off (identity-store.ts's own doc comment on why), while the redeeming device's own redeemed-nonce record survives a restart within the code's own short validity window, enforcing genuine single-use locally rather than merely advisory "please don't reuse this" convention. Redemption never consults the issuer's own ledger -- there is no network round-trip back to whoever generated the code, since the whole point of this bootstrap is working before any connection between the two devices exists.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as openpgp from "openpgp";
|
|
12
|
+
import { nanoid } from "./nanoid.js";
|
|
13
|
+
import {
|
|
14
|
+
loadConnectionCodeLedger,
|
|
15
|
+
saveConnectionCodeLedger,
|
|
16
|
+
} from "./identity-store.js";
|
|
17
|
+
import type { IdentitySlot, StoredConnectionCode } from "./identity-store.js";
|
|
18
|
+
import type { ConnectionCode } from "./types.js";
|
|
19
|
+
|
|
20
|
+
/** Nonce length: longer than nanoid's own 21-character default, since this identifier doubles as the single-use token a redemption check keys off, not merely a display id. */
|
|
21
|
+
const CONNECTION_CODE_NONCE_LENGTH = 32;
|
|
22
|
+
|
|
23
|
+
/** Minutes a generated code stays valid absent an explicit ttlMs override. */
|
|
24
|
+
const DEFAULT_CONNECTION_CODE_TTL_MINUTES = 15;
|
|
25
|
+
/** Seconds per minute, used to convert DEFAULT_CONNECTION_CODE_TTL_MINUTES to milliseconds. */
|
|
26
|
+
const SECONDS_PER_MINUTE = 60;
|
|
27
|
+
/** Milliseconds per second, used to convert DEFAULT_CONNECTION_CODE_TTL_MINUTES to milliseconds. */
|
|
28
|
+
const MS_PER_SECOND = 1000;
|
|
29
|
+
|
|
30
|
+
/** Default validity window for a generated code absent an explicit ttlMs: long enough for a human to relay it out of band (Slack, a phone call), short enough that "freshness" actually means something. */
|
|
31
|
+
export const DEFAULT_CONNECTION_CODE_TTL_MS =
|
|
32
|
+
DEFAULT_CONNECTION_CODE_TTL_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
33
|
+
|
|
34
|
+
/** Every reason ConnectionCodeLedger.redeem can reject a candidate code. */
|
|
35
|
+
export type ConnectionCodeInvalidReason =
|
|
36
|
+
| "expired"
|
|
37
|
+
| "already_redeemed"
|
|
38
|
+
| "signature_required"
|
|
39
|
+
| "signature_invalid"
|
|
40
|
+
| "fingerprint_mismatch";
|
|
41
|
+
|
|
42
|
+
/** Thrown by ConnectionCodeLedger.redeem for every rejection reason above -- callers branch on `reason` rather than parsing `message`. */
|
|
43
|
+
export class ConnectionCodeError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
public readonly reason: ConnectionCodeInvalidReason,
|
|
46
|
+
message: string,
|
|
47
|
+
) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ConnectionCodeError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The exact canonical message a connection code's signature is computed over -- shared by generation and redemption so both sides agree on what was actually signed. Field order and the `:` separator are fixed by this function alone; `code` and `deviceId` are both hex/base64url alphabets that never contain `:`, so the three fields can't be confused with each other under concatenation. */
|
|
54
|
+
function connectionCodeMessage(
|
|
55
|
+
candidate: Readonly<{
|
|
56
|
+
code: string;
|
|
57
|
+
expiresAt: string;
|
|
58
|
+
deviceId: string;
|
|
59
|
+
}>,
|
|
60
|
+
): string {
|
|
61
|
+
return `${candidate.code}:${candidate.expiresAt}:${candidate.deviceId}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Signs `message` with `privateKeyArmored`, decrypting it with `passphrase` first if it is passphrase-protected. Returns the detached, armored PGP signature. */
|
|
65
|
+
async function signConnectionCodeMessage(
|
|
66
|
+
message: string,
|
|
67
|
+
privateKeyArmored: string,
|
|
68
|
+
passphrase?: string,
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
let privateKey = await openpgp.readPrivateKey({
|
|
71
|
+
armoredKey: privateKeyArmored,
|
|
72
|
+
});
|
|
73
|
+
if (!privateKey.isDecrypted()) {
|
|
74
|
+
privateKey = await openpgp.decryptKey(
|
|
75
|
+
passphrase === undefined ? { privateKey } : { privateKey, passphrase },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const unsignedMessage = await openpgp.createMessage({ text: message });
|
|
79
|
+
// openpgp.d.ts's own sign() overloads return a conditional type keyed off T extends WebStream<Data>/NodeWebStream<Data>, which ESLint's own type-aware checking (typescript-eslint's projectService) can't resolve concretely here even though tsc itself has no trouble with it -- widening to `unknown` and narrowing with a runtime typeof check is the honest fix (Type Safety: "unknown with type narrowing"), not a cast papering over a real mismatch tsc would otherwise catch.
|
|
80
|
+
const signResult: unknown = await openpgp.sign({
|
|
81
|
+
message: unsignedMessage,
|
|
82
|
+
signingKeys: privateKey,
|
|
83
|
+
detached: true,
|
|
84
|
+
});
|
|
85
|
+
if (typeof signResult !== "string") {
|
|
86
|
+
throw new TypeError(
|
|
87
|
+
"openpgp.sign returned a stream rather than an armored string -- unexpected for a non-streaming Message input",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return signResult;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The result of checking a connection code's signature against a supplied public key: whether it verified, and (when readable) the signing key's own fingerprint, so a caller can compare it against whatever fingerprint they already trust. */
|
|
94
|
+
interface VerifyConnectionCodeSignatureResult {
|
|
95
|
+
valid: boolean;
|
|
96
|
+
fingerprint?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Verifies `signatureArmored` (a detached PGP signature) over `message` against `publicKeyArmored`. Never throws for an ordinary verification failure (wrong key, tampered message) -- returns `{ valid: false }` instead, reserving thrown errors for malformed input (unparsable armored text). */
|
|
100
|
+
async function verifyConnectionCodeSignature(
|
|
101
|
+
message: string,
|
|
102
|
+
signatureArmored: string,
|
|
103
|
+
publicKeyArmored: string,
|
|
104
|
+
): Promise<VerifyConnectionCodeSignatureResult> {
|
|
105
|
+
const publicKey = await openpgp.readKey({ armoredKey: publicKeyArmored });
|
|
106
|
+
const fingerprint = publicKey.getFingerprint();
|
|
107
|
+
const pgpMessage = await openpgp.createMessage({ text: message });
|
|
108
|
+
const signature = await openpgp.readSignature({
|
|
109
|
+
armoredSignature: signatureArmored,
|
|
110
|
+
});
|
|
111
|
+
const verificationResult = await openpgp.verify({
|
|
112
|
+
message: pgpMessage,
|
|
113
|
+
signature,
|
|
114
|
+
verificationKeys: publicKey,
|
|
115
|
+
});
|
|
116
|
+
const [sigResult] = verificationResult.signatures;
|
|
117
|
+
if (sigResult === undefined) return { valid: false, fingerprint };
|
|
118
|
+
try {
|
|
119
|
+
await sigResult.verified;
|
|
120
|
+
return { valid: true, fingerprint };
|
|
121
|
+
} catch {
|
|
122
|
+
return { valid: false, fingerprint };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Normalises a PGP fingerprint for comparison: openpgp.js's own getFingerprint() returns lowercase hex with no separators, but a human pasting one in from a keyserver page or business card may add spaces, colons, or uppercase. */
|
|
127
|
+
function normalizeFingerprint(fingerprint: string): string {
|
|
128
|
+
return fingerprint.replace(/[\s:]+/g, "").toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface GenerateConnectionCodeOptions {
|
|
132
|
+
/** Overrides DEFAULT_CONNECTION_CODE_TTL_MS. */
|
|
133
|
+
ttlMs?: number;
|
|
134
|
+
/** Armored PGP private key to sign the code with. Omitted entirely -- not merely absent a signature -- when the caller has no PGP identity to sign with; the generated code is then bare, exactly as valid to redeem, just without the optional persistent-identity proof. */
|
|
135
|
+
privateKeyArmored?: string;
|
|
136
|
+
/** Passphrase for privateKeyArmored, if it is passphrase-protected. Ignored if privateKeyArmored is omitted. */
|
|
137
|
+
passphrase?: string;
|
|
138
|
+
/** Injectable clock for tests; defaults to Date.now(). */
|
|
139
|
+
now?: number;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface RedeemConnectionCodeOptions {
|
|
143
|
+
/** The signer's own armored PGP public key, needed only when the candidate code carries a signature. */
|
|
144
|
+
publicKeyArmored?: string;
|
|
145
|
+
/** A PGP fingerprint the caller already has independent reason to trust. When supplied alongside a signed code, the verified signing key's own computed fingerprint must equal it (case- and whitespace-insensitive) or redemption fails with "fingerprint_mismatch". When omitted, a present signature is still verified against publicKeyArmored, but nothing pins it to a fingerprint the caller actually recognises. */
|
|
146
|
+
expectedFingerprint?: string;
|
|
147
|
+
/** Injectable clock for tests; defaults to Date.now(). */
|
|
148
|
+
now?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface RedeemConnectionCodeResult {
|
|
152
|
+
/** The device-id the redeemed code vouched for -- the caller's own responsibility to pass on to GatewayTrust.add. */
|
|
153
|
+
deviceId: string;
|
|
154
|
+
/** The signing key's fingerprint, present only when the code carried a signature that verified. */
|
|
155
|
+
fingerprint?: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Generates and redeems ConnectionCode artifacts, enforcing the always-checked freshness/liveness half (expiry, single-use) and the optional PGP-signature half (checked only when a code carries one). Optionally persisted per bridge slot via identity-store.ts's connection-code ledger (see this file's own header comment for what survives a restart and why); constructed with no slot, stays in-memory only, mirroring GatewayTrust's own no-slot fallback.
|
|
160
|
+
*/
|
|
161
|
+
export class ConnectionCodeLedger {
|
|
162
|
+
private readonly issued = new Map<string, StoredConnectionCode>();
|
|
163
|
+
private readonly redeemed = new Map<string, string>();
|
|
164
|
+
private readonly slot: Readonly<IdentitySlot> | undefined;
|
|
165
|
+
|
|
166
|
+
constructor(slot?: Readonly<IdentitySlot>) {
|
|
167
|
+
this.slot = slot;
|
|
168
|
+
if (slot !== undefined) {
|
|
169
|
+
const ledger = loadConnectionCodeLedger(slot);
|
|
170
|
+
for (const [nonce, record] of Object.entries(ledger.issued)) {
|
|
171
|
+
this.issued.set(nonce, record);
|
|
172
|
+
}
|
|
173
|
+
for (const [nonce, expiresAt] of Object.entries(ledger.redeemed)) {
|
|
174
|
+
this.redeemed.set(nonce, expiresAt);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Drops every issued or redeemed entry whose own expiresAt has already passed, so neither map accumulates forever across a long-running process or a slot that survives many restarts. */
|
|
180
|
+
private prune(now: number): void {
|
|
181
|
+
for (const [nonce, record] of this.issued) {
|
|
182
|
+
if (Date.parse(record.expiresAt) <= now) this.issued.delete(nonce);
|
|
183
|
+
}
|
|
184
|
+
for (const [nonce, expiresAt] of this.redeemed) {
|
|
185
|
+
if (Date.parse(expiresAt) <= now) this.redeemed.delete(nonce);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private persist(): void {
|
|
190
|
+
if (this.slot !== undefined) {
|
|
191
|
+
saveConnectionCodeLedger(this.slot, {
|
|
192
|
+
issued: Object.fromEntries(this.issued),
|
|
193
|
+
redeemed: Object.fromEntries(this.redeemed),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Generates a fresh ConnectionCode vouching for deviceId, optionally signed. The nonce is single-use in the sense that redeem() below will refuse to redeem it twice; nothing prevents the caller from generating any number of independent codes for the same deviceId.
|
|
200
|
+
*/
|
|
201
|
+
async generate(
|
|
202
|
+
deviceId: string,
|
|
203
|
+
options: Readonly<GenerateConnectionCodeOptions> = {},
|
|
204
|
+
): Promise<ConnectionCode> {
|
|
205
|
+
const now = options.now ?? Date.now();
|
|
206
|
+
this.prune(now);
|
|
207
|
+
const code = nanoid(CONNECTION_CODE_NONCE_LENGTH);
|
|
208
|
+
const expiresAt = new Date(
|
|
209
|
+
now + (options.ttlMs ?? DEFAULT_CONNECTION_CODE_TTL_MS),
|
|
210
|
+
).toISOString();
|
|
211
|
+
const signature =
|
|
212
|
+
options.privateKeyArmored === undefined
|
|
213
|
+
? undefined
|
|
214
|
+
: await signConnectionCodeMessage(
|
|
215
|
+
connectionCodeMessage({ code, expiresAt, deviceId }),
|
|
216
|
+
options.privateKeyArmored,
|
|
217
|
+
options.passphrase,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const connectionCode: ConnectionCode =
|
|
221
|
+
signature === undefined
|
|
222
|
+
? { code, expiresAt, deviceId }
|
|
223
|
+
: { code, expiresAt, deviceId, signature };
|
|
224
|
+
const stored: StoredConnectionCode =
|
|
225
|
+
signature === undefined
|
|
226
|
+
? { expiresAt, deviceId }
|
|
227
|
+
: { expiresAt, deviceId, signature };
|
|
228
|
+
this.issued.set(code, stored);
|
|
229
|
+
this.persist();
|
|
230
|
+
return connectionCode;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Validates a candidate ConnectionCode and returns the device-id it vouches for. Always checks expiry and single-use; when the candidate carries a signature, also verifies it (requiring options.publicKeyArmored) and, when options.expectedFingerprint is given, pins the verified signing key to it. Throws ConnectionCodeError for every rejection; the caller is responsible for calling GatewayTrust.add(result.deviceId) on success -- this ledger has no dependency on GatewayTrust itself.
|
|
235
|
+
*/
|
|
236
|
+
async redeem(
|
|
237
|
+
candidate: Readonly<ConnectionCode>,
|
|
238
|
+
options: Readonly<RedeemConnectionCodeOptions> = {},
|
|
239
|
+
): Promise<RedeemConnectionCodeResult> {
|
|
240
|
+
const now = options.now ?? Date.now();
|
|
241
|
+
this.prune(now);
|
|
242
|
+
|
|
243
|
+
if (Date.parse(candidate.expiresAt) <= now) {
|
|
244
|
+
throw new ConnectionCodeError(
|
|
245
|
+
"expired",
|
|
246
|
+
`Connection code expired at ${candidate.expiresAt}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (this.redeemed.has(candidate.code)) {
|
|
250
|
+
throw new ConnectionCodeError(
|
|
251
|
+
"already_redeemed",
|
|
252
|
+
"Connection code has already been redeemed",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let fingerprint: string | undefined;
|
|
257
|
+
if (candidate.signature !== undefined) {
|
|
258
|
+
if (options.publicKeyArmored === undefined) {
|
|
259
|
+
throw new ConnectionCodeError(
|
|
260
|
+
"signature_required",
|
|
261
|
+
"Connection code carries a signature but no public key was supplied to verify it against",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const message = connectionCodeMessage(candidate);
|
|
265
|
+
const verification = await verifyConnectionCodeSignature(
|
|
266
|
+
message,
|
|
267
|
+
candidate.signature,
|
|
268
|
+
options.publicKeyArmored,
|
|
269
|
+
);
|
|
270
|
+
if (!verification.valid) {
|
|
271
|
+
throw new ConnectionCodeError(
|
|
272
|
+
"signature_invalid",
|
|
273
|
+
"Connection code signature does not verify against the supplied public key",
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
fingerprint = verification.fingerprint;
|
|
277
|
+
if (
|
|
278
|
+
options.expectedFingerprint !== undefined &&
|
|
279
|
+
(fingerprint === undefined ||
|
|
280
|
+
normalizeFingerprint(fingerprint) !==
|
|
281
|
+
normalizeFingerprint(options.expectedFingerprint))
|
|
282
|
+
) {
|
|
283
|
+
throw new ConnectionCodeError(
|
|
284
|
+
"fingerprint_mismatch",
|
|
285
|
+
`Signing key fingerprint ${fingerprint ?? "(unknown)"} does not match the expected fingerprint ${options.expectedFingerprint}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this.redeemed.set(candidate.code, candidate.expiresAt);
|
|
291
|
+
this.persist();
|
|
292
|
+
return fingerprint === undefined
|
|
293
|
+
? { deviceId: candidate.deviceId }
|
|
294
|
+
: { deviceId: candidate.deviceId, fingerprint };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoordinatorRole: the elected holder of this machine's coordinator duties (agent-comms#341), the stale-agent PID probe, single-authority departure announcements, and the default cc-peer front. Held by gossiped, term-based claim (wire-mesh-core's CoordinatorElection over the spec's coordinator-frame) rather than by whoever binds the well-known port: a higher term always supersedes a lower one, an equal term breaks by lowest device-id, and any live peer can take the role over by raising the term, so a peer that never bound the port can hold it and the port stays only a compatibility first-contact address.
|
|
3
|
+
*
|
|
4
|
+
* Claims travel as wire-mesh session frames (MeshTransport.broadcastCoordinatorClaim/sendCoordinatorClaim), and only over sessions to peers on this machine, because both duties the role assigns are facts about one machine: a PID probe can only see local processes and the cc-peer front can only reach local Claude Code sessions.
|
|
5
|
+
*
|
|
6
|
+
* When a claim is made: a store claims only when it knows of no incumbent and is the first on the machine as far as it can tell. A store that bound the well-known port with no first-contact presence configured can meet other stores only through that port, so it claims at once; one that may be reachable through first contact (it bound the port, or could neither join nor bind it) first waits one claim wait for an incumbent's announcement, so a store arriving at an existing mesh never takes the role at an equal term before the holder's claim reaches it. Both ends of every machine-local session announce the incumbent they know as the session is enrolled. When the incumbent departs, the lowest device-id among the peers this side has a machine-local session to claims at once at a raised term, and every other survivor claims too if, one claim wait later, the claim it accepts still names a holder it has no session to, which covers a lowest-id peer that is itself gone or was never reachable. The same wait follows accepting a claim whose holder this side has no session to, so a claim naming a device that never joined cannot pin the role vacant.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CoordinatorElection,
|
|
11
|
+
compareDeviceIds,
|
|
12
|
+
type CoordinatorClaim,
|
|
13
|
+
} from "wire-mesh-core/domain/coordinator-election";
|
|
14
|
+
import {
|
|
15
|
+
deviceIdFromHex,
|
|
16
|
+
deviceIdToHex,
|
|
17
|
+
} from "wire-mesh-core/domain/device-id";
|
|
18
|
+
import type { CoordinatorFrame } from "wire-mesh-core/generated/protocol";
|
|
19
|
+
import type { ConnectionHandle, MeshTransport } from "./transport.js";
|
|
20
|
+
|
|
21
|
+
/** The longest delay setTimeout accepts: past it the timer fires at once, which would turn a long backoff into a burst of takeovers. */
|
|
22
|
+
const MAX_TIMER_MS = 0x7fff_ffff;
|
|
23
|
+
|
|
24
|
+
/** The claim this side currently accepts, in the hex form the rest of agent-comms addresses peers by. */
|
|
25
|
+
export interface CoordinatorClaimView {
|
|
26
|
+
term: number;
|
|
27
|
+
holder: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What CoordinatorRole needs from MeshStore. The transport's electionPeerIds, not MeshStore's peer list, decides which peers count as survivors and as reachable holders, because only a peer with a machine-local session can ever claim the role or hear this side's claims. */
|
|
31
|
+
export interface CoordinatorRoleDeps {
|
|
32
|
+
getPeerId: () => string;
|
|
33
|
+
requireTransport: () => Pick<
|
|
34
|
+
MeshTransport,
|
|
35
|
+
"broadcastCoordinatorClaim" | "sendCoordinatorClaim" | "electionPeerIds"
|
|
36
|
+
>;
|
|
37
|
+
/** How long this side waits for an incumbent's claim to reach it before claiming the role itself: after the incumbent departs, after accepting a claim whose holder it has no session to, and before a store that may not be alone claims a vacant role. MeshStore passes ROOM_REQUEST_TIMEOUT_MS, the ordinary bound on a frame reaching a connected peer, unless a test shortens it. */
|
|
38
|
+
claimWaitMs: number;
|
|
39
|
+
/** Starts the duties the role carries. Never runs concurrently with onLost. */
|
|
40
|
+
onGained: () => Promise<void>;
|
|
41
|
+
/** Stops the duties the role carries. Never runs concurrently with onGained. */
|
|
42
|
+
onLost: () => Promise<void>;
|
|
43
|
+
onError: (error: unknown) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sameClaim(
|
|
47
|
+
frame: Readonly<CoordinatorFrame>,
|
|
48
|
+
claim: Readonly<CoordinatorClaim>,
|
|
49
|
+
): boolean {
|
|
50
|
+
return (
|
|
51
|
+
frame.term === claim.term &&
|
|
52
|
+
compareDeviceIds(frame.coordinator, claim.coordinator) === 0
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class CoordinatorRole {
|
|
57
|
+
/** Built on first use rather than at construction, because MeshStore's peerId is assigned after its constructor runs (every bridge sets it to the identity's device-id). */
|
|
58
|
+
private electionInstance: CoordinatorElection | undefined;
|
|
59
|
+
/** Whether the duties are running here, or are being started; set as each transition begins, so onGained and onLost each fire exactly once per change of holder. */
|
|
60
|
+
private holding = false;
|
|
61
|
+
/** Every onGained and onLost, chained so each starts only once the previous one has finished: a role lost and regained in quick succession must not start the duties while they are still being stopped. */
|
|
62
|
+
private transitions: Promise<void> = Promise.resolve();
|
|
63
|
+
/** Set while this side waits out claimWaitMs for an incumbent it can reach. */
|
|
64
|
+
private claimWait: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
private stopped = false;
|
|
66
|
+
/** Takeovers this side has made in a row on an unreachable incumbent. Each doubles the next claim wait, so roles that cannot all reach one holder raise the term ever more slowly instead of at every wait; accepting a claim whose holder this side can reach ends the run. */
|
|
67
|
+
private takeoverStreak = 0;
|
|
68
|
+
/** The claim, as `term:holder`, each peer was last answered with. A peer whose view lags keeps sending the claims it knows, and answering each one with the same incumbent only multiplies the traffic: the answer is repeated only when the incumbent changes or the peer's session is enrolled afresh, since the earlier answer may have been lost with the old session. */
|
|
69
|
+
private readonly answered = new Map<string, string>();
|
|
70
|
+
|
|
71
|
+
constructor(private readonly deps: Readonly<CoordinatorRoleDeps>) {}
|
|
72
|
+
|
|
73
|
+
private get election(): CoordinatorElection {
|
|
74
|
+
this.electionInstance ??= new CoordinatorElection({
|
|
75
|
+
ownDevice: deviceIdFromHex(this.deps.getPeerId()),
|
|
76
|
+
});
|
|
77
|
+
return this.electionInstance;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether this side holds the role now. */
|
|
81
|
+
isHolder(): boolean {
|
|
82
|
+
return this.holding;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The claim this side currently accepts, or undefined when it has neither made nor heard one. */
|
|
86
|
+
current(): CoordinatorClaimView | undefined {
|
|
87
|
+
const claim = this.electionInstance?.current();
|
|
88
|
+
if (claim === undefined) return undefined;
|
|
89
|
+
return { term: claim.term, holder: deviceIdToHex(claim.coordinator) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Claims the role at once when this side knows of no incumbent at all. Called by a store that can meet no other store except through the well-known port it just bound; a no-op once any claim has been made or heard. */
|
|
93
|
+
async claimIfVacant(): Promise<void> {
|
|
94
|
+
if (this.stopped || this.election.current() !== undefined) return;
|
|
95
|
+
await this.claim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Claims the role one claim wait from now, unless by then this side has accepted a claim naming a holder it can reach. Called by a store that is alone as far as the well-known port tells it but may yet meet an incumbent through first contact, which gets that long to announce itself. */
|
|
99
|
+
claimIfVacantAfterWait(): void {
|
|
100
|
+
if (this.stopped || this.election.current() !== undefined) return;
|
|
101
|
+
this.startClaimWait();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Tells a peer whose machine-local session was just enrolled which claim this side accepts, so a joiner learns the incumbent instead of claiming over it. A no-op while this side knows of none. */
|
|
105
|
+
async announceTo(handle: Readonly<ConnectionHandle>): Promise<void> {
|
|
106
|
+
this.answered.delete(handle.id);
|
|
107
|
+
const frame = this.electionInstance?.announceCurrent();
|
|
108
|
+
if (frame === undefined) return;
|
|
109
|
+
await this.deps.requireTransport().sendCoordinatorClaim(handle, frame);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Evaluates a claim a peer gossiped. An accepted claim is gossiped on to every peer so the supersession reaches peers the sender is not connected to, and when it names a holder this side has no session to, this side waits one claim wait for that holder to appear before claiming over it; a stale or tiebreak-losing claim is answered with the incumbent so its sender converges, and a claim whose term no later claim could supersede is dropped unevaluated, neither gossiped nor answered. Receiving the incumbent itself is neither, and is dropped without a reply, which is what stops two peers that agree from echoing it forever. */
|
|
113
|
+
async handleClaim(
|
|
114
|
+
handle: Readonly<ConnectionHandle>,
|
|
115
|
+
frame: Readonly<CoordinatorFrame>,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
if (this.stopped) return;
|
|
118
|
+
const result = this.election.evaluate(frame);
|
|
119
|
+
if (result.outcome === "accepted") {
|
|
120
|
+
this.answered.clear();
|
|
121
|
+
this.cancelClaimWait();
|
|
122
|
+
if (this.incumbentUnreachable()) this.startClaimWait();
|
|
123
|
+
else this.takeoverStreak = 0;
|
|
124
|
+
await this.deps.requireTransport().broadcastCoordinatorClaim(frame);
|
|
125
|
+
} else if (
|
|
126
|
+
result.outcome === "retained" &&
|
|
127
|
+
!sameClaim(frame, result.incumbent)
|
|
128
|
+
) {
|
|
129
|
+
const incumbent = this.election.announceCurrent();
|
|
130
|
+
const answer = `${String(result.incumbent.term)}:${deviceIdToHex(result.incumbent.coordinator)}`;
|
|
131
|
+
if (incumbent !== undefined && this.answered.get(handle.id) !== answer) {
|
|
132
|
+
this.answered.set(handle.id, answer);
|
|
133
|
+
await this.deps
|
|
134
|
+
.requireTransport()
|
|
135
|
+
.sendCoordinatorClaim(handle, incumbent);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
await this.syncHolding();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Recovers the role when the departed peer held it. The lowest device-id among the peers this side has a machine-local session to claims at once at a raised term; every other survivor waits one claim wait and claims itself if the claim it then accepts still names a holder it has no session to, then runs onDelayedTakeover (the caller's single-authority announcement of the departed holder, which a delayed successor makes too). */
|
|
142
|
+
async handleDeparture(
|
|
143
|
+
peerId: string,
|
|
144
|
+
onDelayedTakeover: () => Promise<void>,
|
|
145
|
+
): Promise<void> {
|
|
146
|
+
if (this.stopped || this.current()?.holder !== peerId) return;
|
|
147
|
+
if (this.isLowestSurvivor(peerId)) {
|
|
148
|
+
await this.claim();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
this.startClaimWait(onDelayedTakeover);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Stops taking part and gives the duties up if this side held them, once any transition still running has finished. Idempotent. */
|
|
155
|
+
async stop(): Promise<void> {
|
|
156
|
+
this.stopped = true;
|
|
157
|
+
this.cancelClaimWait();
|
|
158
|
+
await this.syncHolding();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private isLowestSurvivor(departedPeerId: string): boolean {
|
|
162
|
+
const selfId = this.deps.getPeerId();
|
|
163
|
+
const self = deviceIdFromHex(selfId);
|
|
164
|
+
for (const id of this.deps.requireTransport().electionPeerIds()) {
|
|
165
|
+
if (id === departedPeerId || id === selfId) continue;
|
|
166
|
+
if (compareDeviceIds(deviceIdFromHex(id), self) < 0) return false;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether the claim this side accepts leaves the role without a holder it can reach: there is no claim at all, or it names a device that is neither this side nor a peer this side has a machine-local session to. */
|
|
172
|
+
private incumbentUnreachable(): boolean {
|
|
173
|
+
const claim = this.current();
|
|
174
|
+
if (claim === undefined) return true;
|
|
175
|
+
if (claim.holder === this.deps.getPeerId()) return false;
|
|
176
|
+
return !this.deps.requireTransport().electionPeerIds().has(claim.holder);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Waits one claim wait, then claims at a raised term and runs onTakeover (when given) if the incumbent is still unreachable. Replaces any wait already running, since only the latest accepted claim decides whether a takeover is due. */
|
|
180
|
+
private startClaimWait(onTakeover?: () => Promise<void>): void {
|
|
181
|
+
this.cancelClaimWait();
|
|
182
|
+
const wait = Math.min(
|
|
183
|
+
this.deps.claimWaitMs * 2 ** this.takeoverStreak,
|
|
184
|
+
MAX_TIMER_MS,
|
|
185
|
+
);
|
|
186
|
+
this.claimWait = setTimeout(() => {
|
|
187
|
+
this.claimWait = undefined;
|
|
188
|
+
if (this.stopped || !this.incumbentUnreachable()) return;
|
|
189
|
+
this.takeoverStreak += 1;
|
|
190
|
+
void this.claim()
|
|
191
|
+
.then(async () => onTakeover?.())
|
|
192
|
+
.catch(this.deps.onError);
|
|
193
|
+
}, wait);
|
|
194
|
+
// Never what keeps a process alive: a store shutting down stops the role anyway.
|
|
195
|
+
this.claimWait.unref();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async claim(): Promise<void> {
|
|
199
|
+
const frame = this.election.claim();
|
|
200
|
+
await this.deps.requireTransport().broadcastCoordinatorClaim(frame);
|
|
201
|
+
await this.syncHolding();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private cancelClaimWait(): void {
|
|
205
|
+
if (this.claimWait === undefined) return;
|
|
206
|
+
clearTimeout(this.claimWait);
|
|
207
|
+
this.claimWait = undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Queues a transition to whatever holding should now be. Resolves once that transition has run, and rejects with its failure; the chain itself carries on past a failure, which reaches this call's caller instead. */
|
|
211
|
+
private async syncHolding(): Promise<void> {
|
|
212
|
+
const next = this.transitions.then(async () => this.applyHolding());
|
|
213
|
+
this.transitions = next.catch(() => undefined);
|
|
214
|
+
await next;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Starts or stops the duties when the incumbent changed to or from this side since the previous transition. Reads the election afresh when the transition actually runs, not when it was queued, so a claim evaluated while an earlier transition was still running is reflected exactly once. */
|
|
218
|
+
private async applyHolding(): Promise<void> {
|
|
219
|
+
const holds = !this.stopped && this.electionInstance?.isSelf() === true;
|
|
220
|
+
if (holds === this.holding) return;
|
|
221
|
+
this.holding = holds;
|
|
222
|
+
await (holds ? this.deps.onGained() : this.deps.onLost());
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** The environment default for the mesh's persistent keys, trust and ledgers. */
|
|
5
|
+
export const DATA_DIR_ENV_VAR = "AGENT_COMMS_DATA_DIR";
|
|
6
|
+
|
|
7
|
+
export interface DataDirectoryLocation {
|
|
8
|
+
/** Absolute base directory for all persistent mesh data, including the shared account and machine keys. Overrides the environment default and any legacy slot-only dir. */
|
|
9
|
+
dataDir?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Explicit directory first, then the environment setting, then the existing home-directory default. Blank environment values are treated as unset. Non-absolute directories are rejected. */
|
|
13
|
+
export function resolveDataDir({
|
|
14
|
+
dir,
|
|
15
|
+
env = process.env,
|
|
16
|
+
}: Readonly<{
|
|
17
|
+
dir?: string | undefined;
|
|
18
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
19
|
+
}> = {}): string {
|
|
20
|
+
const configured = env[DATA_DIR_ENV_VAR]?.trim();
|
|
21
|
+
const resolved =
|
|
22
|
+
dir ??
|
|
23
|
+
(configured !== undefined && configured !== ""
|
|
24
|
+
? configured
|
|
25
|
+
: path.join(os.homedir(), ".agent-comms"));
|
|
26
|
+
if (!path.isAbsolute(resolved)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`${DATA_DIR_ENV_VAR} or explicit dir must be an absolute path`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|