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,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent bridge identity: load-or-create the TLS key material for a (harness, cwd) slot so the device-id -- and therefore the peer and agent ID -- survives restarts. Also persists a per-room capability token set in the same slot, so a room membership grant survives a restart the same way the identity it was minted against does. Also persists a slot's own trusted-gateway device-id set (agent-comms#186) in a sibling JSON file, so GatewayTrust's allowlist survives a restart the same way; unlike the room/group tokens above, this doesn't require an identity to already exist for the slot, since the trusted set has no dependency on this slot's own key material.
|
|
3
|
+
*
|
|
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
|
+
*
|
|
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 currently generates an entirely fresh key pair rather than re-certifying the existing one, which silently rotates the device-id (and so invalidates any persisted room tokens bound to it) -- a real, separately tracked gap (#68), not addressed here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
resolveDataDir,
|
|
13
|
+
type DataDirectoryLocation,
|
|
14
|
+
} from "./data-directory.js";
|
|
15
|
+
import type { CapabilityToken } from "wire-mesh-core/generated/protocol";
|
|
16
|
+
import {
|
|
17
|
+
generateIdentity,
|
|
18
|
+
certifyKeyPair,
|
|
19
|
+
getCertificateFingerprint,
|
|
20
|
+
deriveDeviceId,
|
|
21
|
+
CERTIFICATE_VALIDITY_MS,
|
|
22
|
+
} from "./identity.js";
|
|
23
|
+
import type { PeerIdentity } from "./identity.js";
|
|
24
|
+
import { writeFileAtomic } from "./atomic-file.js";
|
|
25
|
+
import {
|
|
26
|
+
isPidAlive,
|
|
27
|
+
readLockPid,
|
|
28
|
+
releaseLockFile,
|
|
29
|
+
tryAcquireLock,
|
|
30
|
+
} from "./pid-lock-file.js";
|
|
31
|
+
|
|
32
|
+
/** A bridge's identity slot: one persisted identity per harness and cwd. */
|
|
33
|
+
export interface IdentitySlot extends DataDirectoryLocation {
|
|
34
|
+
harness: string;
|
|
35
|
+
cwd: string;
|
|
36
|
+
/** Directory override for tests. */
|
|
37
|
+
dir?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
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
|
+
/** A persisted identity/token/trust/ledger file holds real key material or capability tokens, so it is written owner-only. */
|
|
46
|
+
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
|
+
|
|
50
|
+
/** 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
|
+
export type SerializedCapabilityToken = [
|
|
52
|
+
string,
|
|
53
|
+
{ "1"?: number; "4"?: string; [key: string]: unknown },
|
|
54
|
+
string | null,
|
|
55
|
+
string,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
interface StoredIdentity {
|
|
59
|
+
privateKey: string;
|
|
60
|
+
certificate: string;
|
|
61
|
+
expiresAt: string;
|
|
62
|
+
roomTokens?: Record<string, SerializedCapabilityToken>;
|
|
63
|
+
/** 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
|
+
issuedGrants?: Record<string, string>;
|
|
65
|
+
/** This device's own held group:member token(s) (agent-comms#161), keyed by group path (the admitting user principal's own device-id in hex, core/user-identity.ts's own userGroupPath) -- the device-membership counterpart of roomTokens above, holding a token this identity BEARS proving the user principal admitted it, never one it issued itself (a device is never the issuer of its own membership). */
|
|
66
|
+
groupTokens?: Record<string, SerializedCapabilityToken>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A COSE_Sign1 tuple always has exactly 4 elements: protected header, unprotected header, payload, signature. */
|
|
70
|
+
const COSE_SIGN1_TUPLE_LENGTH = 4;
|
|
71
|
+
|
|
72
|
+
export function isSerializedCapabilityToken(
|
|
73
|
+
value: unknown,
|
|
74
|
+
): value is SerializedCapabilityToken {
|
|
75
|
+
if (!Array.isArray(value) || value.length !== COSE_SIGN1_TUPLE_LENGTH)
|
|
76
|
+
return false;
|
|
77
|
+
const protectedHeader: unknown = value[0];
|
|
78
|
+
const unprotectedHeader: unknown = value[1];
|
|
79
|
+
const payload: unknown = value[2];
|
|
80
|
+
const signature: unknown = value[3];
|
|
81
|
+
if (typeof protectedHeader !== "string") return false;
|
|
82
|
+
if (typeof unprotectedHeader !== "object" || unprotectedHeader === null)
|
|
83
|
+
return false;
|
|
84
|
+
if (payload !== null && typeof payload !== "string") return false;
|
|
85
|
+
return typeof signature === "string";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isStoredIdentity(value: unknown): value is StoredIdentity {
|
|
89
|
+
if (typeof value !== "object" || value === null) return false;
|
|
90
|
+
if (
|
|
91
|
+
!("privateKey" in value) ||
|
|
92
|
+
!("certificate" in value) ||
|
|
93
|
+
!("expiresAt" in value)
|
|
94
|
+
)
|
|
95
|
+
return false;
|
|
96
|
+
if (
|
|
97
|
+
typeof value.privateKey !== "string" ||
|
|
98
|
+
typeof value.certificate !== "string" ||
|
|
99
|
+
typeof value.expiresAt !== "string"
|
|
100
|
+
)
|
|
101
|
+
return false;
|
|
102
|
+
if ("roomTokens" in value) {
|
|
103
|
+
const { roomTokens } = value;
|
|
104
|
+
if (typeof roomTokens !== "object" || roomTokens === null) return false;
|
|
105
|
+
if (!Object.values(roomTokens).every(isSerializedCapabilityToken))
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
if ("issuedGrants" in value) {
|
|
109
|
+
const { issuedGrants } = value;
|
|
110
|
+
if (typeof issuedGrants !== "object" || issuedGrants === null) return false;
|
|
111
|
+
if (!Object.values(issuedGrants).every((v) => typeof v === "string"))
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
if ("groupTokens" in value) {
|
|
115
|
+
const { groupTokens } = value;
|
|
116
|
+
if (typeof groupTokens !== "object" || groupTokens === null) return false;
|
|
117
|
+
if (!Object.values(groupTokens).every(isSerializedCapabilityToken))
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A capability token as plain JSON: every byte string base64-encoded. */
|
|
124
|
+
export function serializeToken(
|
|
125
|
+
token: CapabilityToken,
|
|
126
|
+
): SerializedCapabilityToken {
|
|
127
|
+
const [protectedHeader, unprotectedHeader, payload, signature] = token;
|
|
128
|
+
const serializedUnprotected: SerializedCapabilityToken[1] = {
|
|
129
|
+
...(unprotectedHeader["1"] !== undefined
|
|
130
|
+
? { "1": unprotectedHeader["1"] }
|
|
131
|
+
: {}),
|
|
132
|
+
...(unprotectedHeader["4"] !== undefined
|
|
133
|
+
? { "4": Buffer.from(unprotectedHeader["4"]).toString("base64") }
|
|
134
|
+
: {}),
|
|
135
|
+
};
|
|
136
|
+
return [
|
|
137
|
+
Buffer.from(protectedHeader).toString("base64"),
|
|
138
|
+
serializedUnprotected,
|
|
139
|
+
payload === null ? null : Buffer.from(payload).toString("base64"),
|
|
140
|
+
Buffer.from(signature).toString("base64"),
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The inverse of serializeToken. */
|
|
145
|
+
export function deserializeToken(
|
|
146
|
+
serialized: SerializedCapabilityToken,
|
|
147
|
+
): CapabilityToken {
|
|
148
|
+
const [protectedHeader, unprotectedHeader, payload, signature] = serialized;
|
|
149
|
+
const deserializedUnprotected: CapabilityToken[1] = {
|
|
150
|
+
...(unprotectedHeader["1"] !== undefined
|
|
151
|
+
? { "1": unprotectedHeader["1"] }
|
|
152
|
+
: {}),
|
|
153
|
+
...(unprotectedHeader["4"] !== undefined
|
|
154
|
+
? {
|
|
155
|
+
"4": Uint8Array.from(Buffer.from(unprotectedHeader["4"], "base64")),
|
|
156
|
+
}
|
|
157
|
+
: {}),
|
|
158
|
+
};
|
|
159
|
+
return [
|
|
160
|
+
Uint8Array.from(Buffer.from(protectedHeader, "base64")),
|
|
161
|
+
deserializedUnprotected,
|
|
162
|
+
payload === null ? null : Uint8Array.from(Buffer.from(payload, "base64")),
|
|
163
|
+
Uint8Array.from(Buffer.from(signature, "base64")),
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Replace path separators and other filesystem-hostile characters. */
|
|
168
|
+
function slugifyCwd(cwd: string): string {
|
|
169
|
+
return cwd.replace(/[\\/:*?"<>|]/g, "_");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The directory every file this module owns lives in. */
|
|
173
|
+
function identityDir(
|
|
174
|
+
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
|
|
175
|
+
): string {
|
|
176
|
+
return resolveDataDir({ dir: location.dataDir ?? location.dir });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function slotPaths(slot: Readonly<IdentitySlot>): {
|
|
180
|
+
dir: string;
|
|
181
|
+
identityFile: string;
|
|
182
|
+
lockFile: string;
|
|
183
|
+
} {
|
|
184
|
+
const dir = identityDir(slot);
|
|
185
|
+
const base = `identity-${slot.harness}--${slugifyCwd(slot.cwd)}`;
|
|
186
|
+
return {
|
|
187
|
+
dir,
|
|
188
|
+
identityFile: path.join(dir, `${base}.json`),
|
|
189
|
+
lockFile: path.join(dir, `${base}.lock`),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Directory this slot's own room-notice oplog is stored under (createNodeFsStorage's own dir option), mirroring the identity file's per-(harness, cwd) naming convention -- a sibling directory rather than a sibling file, since an oplog needs its own directory tree (one entry per sequence number) rather than a single JSON blob. */
|
|
194
|
+
export function oplogDirFor(slot: Readonly<IdentitySlot>): string {
|
|
195
|
+
const { dir } = slotPaths(slot);
|
|
196
|
+
const base = `oplog-${slot.harness}--${slugifyCwd(slot.cwd)}`;
|
|
197
|
+
return path.join(dir, base);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function persistIdentity(identityFile: string, identity: PeerIdentity): void {
|
|
201
|
+
const stored: StoredIdentity = {
|
|
202
|
+
privateKey: identity.privateKey,
|
|
203
|
+
certificate: identity.certificate,
|
|
204
|
+
expiresAt: new Date(Date.now() + CERTIFICATE_VALIDITY_MS).toISOString(),
|
|
205
|
+
};
|
|
206
|
+
writeFileAtomic(
|
|
207
|
+
identityFile,
|
|
208
|
+
`${JSON.stringify(stored, null, 2)}\n`,
|
|
209
|
+
OWNER_ONLY_RW_PERMISSIONS,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 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.
|
|
215
|
+
*/
|
|
216
|
+
export function loadOrCreateIdentity(
|
|
217
|
+
slot: Readonly<IdentitySlot>,
|
|
218
|
+
): PeerIdentity {
|
|
219
|
+
const { dir, identityFile, lockFile } = slotPaths(slot);
|
|
220
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
221
|
+
|
|
222
|
+
const claim = tryAcquireLock(lockFile);
|
|
223
|
+
if (!claim.acquired) {
|
|
224
|
+
console.error(
|
|
225
|
+
`agent-comms: identity slot ${slot.harness} (${slot.cwd}) is held by live pid ${String(claim.heldBy)}; running with an ephemeral identity`,
|
|
226
|
+
);
|
|
227
|
+
return generateIdentity();
|
|
228
|
+
}
|
|
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;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
if (needsRenewal) {
|
|
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
|
+
};
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.error(
|
|
269
|
+
`agent-comms: stored identity at ${identityFile} is unreadable (${err instanceof Error ? err.message : String(err)}); generating a fresh identity`,
|
|
270
|
+
);
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function createIdentity(identityFile: string): PeerIdentity {
|
|
276
|
+
const identity = generateIdentity();
|
|
277
|
+
persistIdentity(identityFile, identity);
|
|
278
|
+
return identity;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 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
|
+
*/
|
|
284
|
+
export function probeSlotOwner(
|
|
285
|
+
slot: Readonly<IdentitySlot>,
|
|
286
|
+
): number | undefined {
|
|
287
|
+
const { lockFile } = slotPaths(slot);
|
|
288
|
+
const heldBy = readLockPid(lockFile);
|
|
289
|
+
if (heldBy === undefined) return undefined;
|
|
290
|
+
return isPidAlive(heldBy) ? heldBy : undefined;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
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 -- 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
|
+
*/
|
|
296
|
+
export function loadIdentityForFront(
|
|
297
|
+
slot: Readonly<IdentitySlot>,
|
|
298
|
+
): PeerIdentity {
|
|
299
|
+
const { dir, identityFile } = slotPaths(slot);
|
|
300
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
301
|
+
return loadStoredIdentity(identityFile) ?? createIdentity(identityFile);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Release the slot lock on graceful shutdown. A lock held by another PID (taken over after this process crashed and restarted) is left alone.
|
|
306
|
+
*/
|
|
307
|
+
export function releaseIdentityLock(slot: Readonly<IdentitySlot>): void {
|
|
308
|
+
releaseLockFile(slotPaths(slot).lockFile);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Reads the slot's raw stored identity record, or undefined if the file is missing or unparseable. Used by the room-token functions below to read-modify-write only the roomTokens field, leaving the persisted key material exactly as it is. */
|
|
312
|
+
function readStoredIdentity(identityFile: string): StoredIdentity | undefined {
|
|
313
|
+
let parsed: unknown;
|
|
314
|
+
try {
|
|
315
|
+
parsed = JSON.parse(fs.readFileSync(identityFile, "utf-8"));
|
|
316
|
+
} catch {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return isStoredIdentity(parsed) ? parsed : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function writeStoredIdentity(
|
|
323
|
+
identityFile: string,
|
|
324
|
+
stored: StoredIdentity,
|
|
325
|
+
): void {
|
|
326
|
+
writeFileAtomic(
|
|
327
|
+
identityFile,
|
|
328
|
+
`${JSON.stringify(stored, null, 2)}\n`,
|
|
329
|
+
OWNER_ONLY_RW_PERMISSIONS,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
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 createIdentity call (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
|
+
const CONCURRENT_CREATE_WAIT_MS = 2000;
|
|
335
|
+
// Poll interval while waiting -- deliberately short, since the wait is expected to resolve in low single-digit milliseconds in the overwhelmingly common case.
|
|
336
|
+
const CONCURRENT_CREATE_POLL_MS = 10;
|
|
337
|
+
|
|
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
|
+
/**
|
|
344
|
+
* Reads the slot's stored identity, waiting briefly for it 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 ephemeral loser should wait for the real winner to finish rather than fail outright. Falls through to the ordinary "missing" result (undefined) once the lock is no longer held by a different live process, or the wait budget runs out -- both signal there is genuinely nothing to wait for.
|
|
345
|
+
*/
|
|
346
|
+
function readStoredIdentityWaitingForConcurrentCreate(
|
|
347
|
+
identityFile: string,
|
|
348
|
+
lockFile: string,
|
|
349
|
+
): StoredIdentity | undefined {
|
|
350
|
+
const deadline = Date.now() + CONCURRENT_CREATE_WAIT_MS;
|
|
351
|
+
for (;;) {
|
|
352
|
+
const stored = readStoredIdentity(identityFile);
|
|
353
|
+
if (stored !== undefined) return stored;
|
|
354
|
+
|
|
355
|
+
const heldBy = readLockPid(lockFile);
|
|
356
|
+
const heldByDifferentLiveProcess =
|
|
357
|
+
heldBy !== undefined && heldBy !== process.pid && isPidAlive(heldBy);
|
|
358
|
+
// The holder can finish creating the file and exit between the read above and this lock check, so the file is read once more before concluding it was never created.
|
|
359
|
+
if (!heldByDifferentLiveProcess || Date.now() >= deadline) {
|
|
360
|
+
return readStoredIdentity(identityFile);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
sleepSync(CONCURRENT_CREATE_POLL_MS);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Every room-membership capability token persisted for this slot, keyed by room path. Empty if the slot has never had one saved (or the identity file doesn't exist yet -- call loadOrCreateIdentity first).
|
|
369
|
+
*/
|
|
370
|
+
export function loadRoomTokens(
|
|
371
|
+
slot: Readonly<IdentitySlot>,
|
|
372
|
+
): Record<string, CapabilityToken> {
|
|
373
|
+
const { identityFile } = slotPaths(slot);
|
|
374
|
+
const stored = readStoredIdentity(identityFile);
|
|
375
|
+
const serialized = stored?.roomTokens ?? {};
|
|
376
|
+
const tokens: Record<string, CapabilityToken> = {};
|
|
377
|
+
for (const [roomPath, token] of Object.entries(serialized)) {
|
|
378
|
+
tokens[roomPath] = deserializeToken(token);
|
|
379
|
+
}
|
|
380
|
+
return tokens;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
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.
|
|
385
|
+
*/
|
|
386
|
+
export function saveRoomToken(
|
|
387
|
+
slot: Readonly<IdentitySlot>,
|
|
388
|
+
roomPath: string,
|
|
389
|
+
token: CapabilityToken,
|
|
390
|
+
): void {
|
|
391
|
+
const { identityFile, lockFile } = slotPaths(slot);
|
|
392
|
+
const stored = readStoredIdentityWaitingForConcurrentCreate(
|
|
393
|
+
identityFile,
|
|
394
|
+
lockFile,
|
|
395
|
+
);
|
|
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
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Removes the persisted token for one room path, if any. A no-op if none was saved for that path. */
|
|
409
|
+
export function deleteRoomToken(
|
|
410
|
+
slot: Readonly<IdentitySlot>,
|
|
411
|
+
roomPath: string,
|
|
412
|
+
): void {
|
|
413
|
+
const { identityFile } = slotPaths(slot);
|
|
414
|
+
const stored = readStoredIdentity(identityFile);
|
|
415
|
+
if (stored?.roomTokens === undefined) return;
|
|
416
|
+
const roomTokens = Object.fromEntries(
|
|
417
|
+
Object.entries(stored.roomTokens).filter(
|
|
418
|
+
([storedRoomPath]) => storedRoomPath !== roomPath,
|
|
419
|
+
),
|
|
420
|
+
);
|
|
421
|
+
writeStoredIdentity(identityFile, { ...stored, roomTokens });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Every group-membership capability token this device's own identity holds (agent-comms#161), keyed by group path. Empty if this slot has never saved one (or the identity file doesn't exist yet -- call loadOrCreateIdentity first). The device-membership counterpart of loadRoomTokens.
|
|
426
|
+
*/
|
|
427
|
+
export function loadGroupTokens(
|
|
428
|
+
slot: Readonly<IdentitySlot>,
|
|
429
|
+
): Record<string, CapabilityToken> {
|
|
430
|
+
const { identityFile } = slotPaths(slot);
|
|
431
|
+
const stored = readStoredIdentity(identityFile);
|
|
432
|
+
const serialized = stored?.groupTokens ?? {};
|
|
433
|
+
const tokens: Record<string, CapabilityToken> = {};
|
|
434
|
+
for (const [groupPath, token] of Object.entries(serialized)) {
|
|
435
|
+
tokens[groupPath] = deserializeToken(token);
|
|
436
|
+
}
|
|
437
|
+
return tokens;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 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
|
+
*/
|
|
443
|
+
export function saveGroupToken(
|
|
444
|
+
slot: Readonly<IdentitySlot>,
|
|
445
|
+
groupPath: string,
|
|
446
|
+
token: CapabilityToken,
|
|
447
|
+
): void {
|
|
448
|
+
const { identityFile, lockFile } = slotPaths(slot);
|
|
449
|
+
const stored = readStoredIdentityWaitingForConcurrentCreate(
|
|
450
|
+
identityFile,
|
|
451
|
+
lockFile,
|
|
452
|
+
);
|
|
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
|
+
}
|
|
464
|
+
|
|
465
|
+
/** 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(
|
|
467
|
+
slot: Readonly<IdentitySlot>,
|
|
468
|
+
groupPath: string,
|
|
469
|
+
): void {
|
|
470
|
+
const { identityFile } = slotPaths(slot);
|
|
471
|
+
const stored = readStoredIdentity(identityFile);
|
|
472
|
+
if (stored?.groupTokens === undefined) return;
|
|
473
|
+
const groupTokens = Object.fromEntries(
|
|
474
|
+
Object.entries(stored.groupTokens).filter(
|
|
475
|
+
([storedGroupPath]) => storedGroupPath !== groupPath,
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
writeStoredIdentity(identityFile, { ...stored, groupTokens });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function issuedGrantKey(roomPath: string, memberDeviceHex: string): string {
|
|
482
|
+
return `${roomPath}::${memberDeviceHex}`;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The token-id this identity itself minted for the given member's room:member grant, or undefined if none is on record (the slot has never admitted this member, or the record predates this bookkeeping). A room owner consults this to revoke a specific member's own grant later -- a token-id, unlike the token itself, is never presented on the wire and so is never obtainable except from this identity's own memory of having minted it.
|
|
487
|
+
*/
|
|
488
|
+
export function loadIssuedRoomGrant(
|
|
489
|
+
slot: Readonly<IdentitySlot>,
|
|
490
|
+
roomPath: string,
|
|
491
|
+
memberDeviceHex: string,
|
|
492
|
+
): Uint8Array<ArrayBuffer> | undefined {
|
|
493
|
+
const { identityFile } = slotPaths(slot);
|
|
494
|
+
const stored = readStoredIdentity(identityFile);
|
|
495
|
+
const encoded =
|
|
496
|
+
stored?.issuedGrants?.[issuedGrantKey(roomPath, memberDeviceHex)];
|
|
497
|
+
return encoded === undefined
|
|
498
|
+
? undefined
|
|
499
|
+
: Uint8Array.from(Buffer.from(encoded, "base64"));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 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
|
+
*/
|
|
505
|
+
export function saveIssuedRoomGrant(
|
|
506
|
+
slot: Readonly<IdentitySlot>,
|
|
507
|
+
roomPath: string,
|
|
508
|
+
memberDeviceHex: string,
|
|
509
|
+
tokenId: Uint8Array,
|
|
510
|
+
): void {
|
|
511
|
+
const { identityFile, lockFile } = slotPaths(slot);
|
|
512
|
+
const stored = readStoredIdentityWaitingForConcurrentCreate(
|
|
513
|
+
identityFile,
|
|
514
|
+
lockFile,
|
|
515
|
+
);
|
|
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
|
+
}
|
|
528
|
+
|
|
529
|
+
/** 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(
|
|
531
|
+
slot: Readonly<IdentitySlot>,
|
|
532
|
+
roomPath: string,
|
|
533
|
+
memberDeviceHex: string,
|
|
534
|
+
): void {
|
|
535
|
+
const { identityFile } = slotPaths(slot);
|
|
536
|
+
const stored = readStoredIdentity(identityFile);
|
|
537
|
+
if (stored?.issuedGrants === undefined) return;
|
|
538
|
+
const key = issuedGrantKey(roomPath, memberDeviceHex);
|
|
539
|
+
const issuedGrants = Object.fromEntries(
|
|
540
|
+
Object.entries(stored.issuedGrants).filter(([k]) => k !== key),
|
|
541
|
+
);
|
|
542
|
+
writeStoredIdentity(identityFile, { ...stored, issuedGrants });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** 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. */
|
|
546
|
+
function gatewayTrustFilePath(
|
|
547
|
+
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
|
|
548
|
+
): string {
|
|
549
|
+
return path.join(identityDir(location), "gateway-trust.json");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** The persisted trusted sets: every remote device-id, every remote user-principal device-id (agent-comms#187) and every remote machine device-id (agent-comms#343) this machine's gateway currently trusts, each lowercase hex, in insertion order. */
|
|
553
|
+
export interface LoadedGatewayTrust {
|
|
554
|
+
devices: string[];
|
|
555
|
+
principals: string[];
|
|
556
|
+
machines: string[];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** What gatewayTrustStamp reports while no trust file exists. */
|
|
560
|
+
const GATEWAY_TRUST_ABSENT_STAMP = "absent";
|
|
561
|
+
|
|
562
|
+
function isStringArray(value: unknown): value is string[] {
|
|
563
|
+
return (
|
|
564
|
+
Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The trusted devices, principals (agent-comms#187) and machines (agent-comms#343) persisted for this identity directory before this call, each empty if nothing has been saved or the file is missing or unparseable. A file written before machines were trusted has no machines list and loads with an empty one.
|
|
570
|
+
*/
|
|
571
|
+
export function loadGatewayTrust(
|
|
572
|
+
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
|
|
573
|
+
): LoadedGatewayTrust {
|
|
574
|
+
let parsed: unknown;
|
|
575
|
+
try {
|
|
576
|
+
parsed = JSON.parse(
|
|
577
|
+
fs.readFileSync(gatewayTrustFilePath(location), "utf-8"),
|
|
578
|
+
);
|
|
579
|
+
} catch {
|
|
580
|
+
return { devices: [], principals: [], machines: [] };
|
|
581
|
+
}
|
|
582
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
583
|
+
return { devices: [], principals: [], machines: [] };
|
|
584
|
+
}
|
|
585
|
+
const devices =
|
|
586
|
+
"devices" in parsed && isStringArray(parsed.devices) ? parsed.devices : [];
|
|
587
|
+
const principals =
|
|
588
|
+
"principals" in parsed && isStringArray(parsed.principals)
|
|
589
|
+
? parsed.principals
|
|
590
|
+
: [];
|
|
591
|
+
const machines =
|
|
592
|
+
"machines" in parsed && isStringArray(parsed.machines)
|
|
593
|
+
? parsed.machines
|
|
594
|
+
: [];
|
|
595
|
+
return { devices, principals, machines };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Persists the complete trusted device, principal and machine sets for this identity directory, surviving a restart. Overwrites whatever was saved before in full: GatewayTrust calls this with its own current sets after every change, having first reloaded the file if another process replaced it, so there is no per-entry partial update to preserve here.
|
|
600
|
+
*/
|
|
601
|
+
export function saveGatewayTrust(
|
|
602
|
+
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
|
|
603
|
+
trust: Readonly<LoadedGatewayTrust>,
|
|
604
|
+
): void {
|
|
605
|
+
const dir = identityDir(location);
|
|
606
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
607
|
+
const stored: LoadedGatewayTrust = {
|
|
608
|
+
devices: [...trust.devices],
|
|
609
|
+
principals: [...trust.principals],
|
|
610
|
+
machines: [...trust.machines],
|
|
611
|
+
};
|
|
612
|
+
writeFileAtomic(
|
|
613
|
+
gatewayTrustFilePath(location),
|
|
614
|
+
`${JSON.stringify(stored, null, 2)}\n`,
|
|
615
|
+
OWNER_ONLY_RW_PERMISSIONS,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* A token that changes whenever the gateway trust file is replaced, or is created or removed, by any process: the file's inode, modification time and size, or a fixed marker while it does not exist. Every save goes through writeFileAtomic, a rename, so each write yields a new inode and the token differs even when two writes land within one timestamp tick. GatewayTrust compares it to notice that another bridge on the machine changed the shared allowlist.
|
|
621
|
+
*/
|
|
622
|
+
export function gatewayTrustStamp(
|
|
623
|
+
location: Readonly<Pick<IdentitySlot, "dir" | "dataDir">>,
|
|
624
|
+
): string {
|
|
625
|
+
try {
|
|
626
|
+
const stat = fs.statSync(gatewayTrustFilePath(location));
|
|
627
|
+
return `${String(stat.ino)}:${String(stat.mtimeMs)}:${String(stat.size)}`;
|
|
628
|
+
} catch {
|
|
629
|
+
return GATEWAY_TRUST_ABSENT_STAMP;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** A generated connection code's own persisted record (agent-comms#188): everything ConnectionCode carries except its own nonce, which is this record's key instead of a repeated field. */
|
|
634
|
+
export interface StoredConnectionCode {
|
|
635
|
+
expiresAt: string;
|
|
636
|
+
deviceId: string;
|
|
637
|
+
signature?: string;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** A slot's own connection-code ledger (agent-comms#188): every code this slot has generated (still outstanding, so the artifact survives a restart between generation and hand-off) and every nonce this slot has redeemed (so a restart can't reopen a single-use code to a second redemption within its own short validity window). Two independent halves of one bootstrap flow, not a request/response pair -- a generated code's own record here is never consulted by whichever remote slot later redeems it, since redemption validates the code's four self-contained fields directly rather than calling back to its issuer. */
|
|
641
|
+
export interface ConnectionCodeLedgerData {
|
|
642
|
+
issued: Record<string, StoredConnectionCode>;
|
|
643
|
+
redeemed: Record<string, string>;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** A slot's own connection-code ledger lives in its own sibling JSON file, mirroring gatewayTrustFilePath's own reasoning: the ledger has no dependency on this slot's own key material, so ConnectionCodeLedger can be constructed against a slot before or independently of that slot's identity ever being loaded. */
|
|
647
|
+
function connectionCodesFilePath(slot: Readonly<IdentitySlot>): string {
|
|
648
|
+
const { dir } = slotPaths(slot);
|
|
649
|
+
const base = `connection-codes-${slot.harness}--${slugifyCwd(slot.cwd)}`;
|
|
650
|
+
return path.join(dir, `${base}.json`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function isStoredConnectionCode(value: unknown): value is StoredConnectionCode {
|
|
654
|
+
if (typeof value !== "object" || value === null) return false;
|
|
655
|
+
if (!("expiresAt" in value) || !("deviceId" in value)) return false;
|
|
656
|
+
if (typeof value.expiresAt !== "string" || typeof value.deviceId !== "string")
|
|
657
|
+
return false;
|
|
658
|
+
if ("signature" in value && typeof value.signature !== "string") return false;
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* A slot's currently persisted connection-code ledger: every code it has issued, keyed by nonce, and every nonce it has redeemed, mapped to that code's own expiresAt (kept only so a stale entry can be pruned once its expiry has passed, the same reason issued entries carry their own expiresAt). Both halves default to empty if the slot has never saved a ledger, or its ledger file is missing or unparseable.
|
|
664
|
+
*/
|
|
665
|
+
export function loadConnectionCodeLedger(
|
|
666
|
+
slot: Readonly<IdentitySlot>,
|
|
667
|
+
): ConnectionCodeLedgerData {
|
|
668
|
+
let parsed: unknown;
|
|
669
|
+
try {
|
|
670
|
+
parsed = JSON.parse(
|
|
671
|
+
fs.readFileSync(connectionCodesFilePath(slot), "utf-8"),
|
|
672
|
+
);
|
|
673
|
+
} catch {
|
|
674
|
+
return { issued: {}, redeemed: {} };
|
|
675
|
+
}
|
|
676
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
677
|
+
return { issued: {}, redeemed: {} };
|
|
678
|
+
const issuedRaw =
|
|
679
|
+
"issued" in parsed &&
|
|
680
|
+
typeof parsed.issued === "object" &&
|
|
681
|
+
parsed.issued !== null
|
|
682
|
+
? parsed.issued
|
|
683
|
+
: {};
|
|
684
|
+
const redeemedRaw =
|
|
685
|
+
"redeemed" in parsed &&
|
|
686
|
+
typeof parsed.redeemed === "object" &&
|
|
687
|
+
parsed.redeemed !== null
|
|
688
|
+
? parsed.redeemed
|
|
689
|
+
: {};
|
|
690
|
+
const issued: Record<string, StoredConnectionCode> = {};
|
|
691
|
+
for (const [nonce, record] of Object.entries(issuedRaw)) {
|
|
692
|
+
if (isStoredConnectionCode(record)) issued[nonce] = record;
|
|
693
|
+
}
|
|
694
|
+
const redeemed: Record<string, string> = {};
|
|
695
|
+
for (const [nonce, expiresAt] of Object.entries(redeemedRaw)) {
|
|
696
|
+
if (typeof expiresAt === "string") redeemed[nonce] = expiresAt;
|
|
697
|
+
}
|
|
698
|
+
return { issued, redeemed };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Persists a slot's complete connection-code ledger, surviving a restart the same way the identity it bootstraps trust for does. Overwrites whatever was saved before in full: ConnectionCodeLedger always calls this with its own current issued/redeemed maps after every generate/redeem, so there is no per-code partial update to preserve here the way saveRoomToken preserves other rooms' tokens.
|
|
703
|
+
*/
|
|
704
|
+
export function saveConnectionCodeLedger(
|
|
705
|
+
slot: Readonly<IdentitySlot>,
|
|
706
|
+
ledger: Readonly<ConnectionCodeLedgerData>,
|
|
707
|
+
): void {
|
|
708
|
+
const { dir } = slotPaths(slot);
|
|
709
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
710
|
+
writeFileAtomic(
|
|
711
|
+
connectionCodesFilePath(slot),
|
|
712
|
+
`${JSON.stringify(ledger, null, 2)}\n`,
|
|
713
|
+
OWNER_ONLY_RW_PERMISSIONS,
|
|
714
|
+
);
|
|
715
|
+
}
|