agent-embassy 2.0.1 → 3.1.0
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/CHANGELOG.md +77 -0
- package/CONTRIBUTING.md +23 -36
- package/README.md +123 -222
- package/SECURITY.md +72 -93
- package/dist/src/errors.d.ts +10 -1
- package/dist/src/errors.js +3 -1
- package/dist/src/errors.js.map +1 -1
- package/dist/src/gateway/claude-helper-protocol.d.ts +8 -11
- package/dist/src/gateway/claude-helper-protocol.js +12 -11
- package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
- package/dist/src/gateway/claude-helper-supervisor.d.ts +1 -5
- package/dist/src/gateway/claude-helper-supervisor.js +45 -17
- package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
- package/dist/src/gateway/claude-helper.js +6 -6
- package/dist/src/gateway/claude-helper.js.map +1 -1
- package/dist/src/gateway/claude-peer.d.ts +0 -3
- package/dist/src/gateway/claude-peer.js +7 -19
- package/dist/src/gateway/claude-peer.js.map +1 -1
- package/dist/src/gateway/cli.d.ts +17 -7
- package/dist/src/gateway/cli.js +945 -265
- package/dist/src/gateway/cli.js.map +1 -1
- package/dist/src/gateway/codex-socket-holder.d.ts +26 -0
- package/dist/src/gateway/codex-socket-holder.js +76 -0
- package/dist/src/gateway/codex-socket-holder.js.map +1 -0
- package/dist/src/gateway/codex-stateless-transport.js +1 -1
- package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
- package/dist/src/gateway/config.d.ts +1 -10
- package/dist/src/gateway/config.js +4 -10
- package/dist/src/gateway/config.js.map +1 -1
- package/dist/src/gateway/control.d.ts +66 -80
- package/dist/src/gateway/control.js +92 -147
- package/dist/src/gateway/control.js.map +1 -1
- package/dist/src/gateway/federation-nodes.d.ts +29 -2
- package/dist/src/gateway/federation-nodes.js +177 -7
- package/dist/src/gateway/federation-nodes.js.map +1 -1
- package/dist/src/gateway/peer-client.d.ts +4 -3
- package/dist/src/gateway/peer-client.js +22 -13
- package/dist/src/gateway/peer-client.js.map +1 -1
- package/dist/src/gateway/peer-protocol.d.ts +13 -11
- package/dist/src/gateway/peer-protocol.js +14 -12
- package/dist/src/gateway/peer-protocol.js.map +1 -1
- package/dist/src/gateway/provenance-envelope.d.ts +0 -1
- package/dist/src/gateway/provenance-envelope.js +4 -19
- package/dist/src/gateway/provenance-envelope.js.map +1 -1
- package/dist/src/gateway/providers.d.ts +11 -7
- package/dist/src/gateway/providers.js +49 -50
- package/dist/src/gateway/providers.js.map +1 -1
- package/dist/src/gateway/server.d.ts +4 -14
- package/dist/src/gateway/server.js +32 -54
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/src/gateway/service-agent.d.ts +187 -0
- package/dist/src/gateway/service-agent.js +758 -0
- package/dist/src/gateway/service-agent.js.map +1 -0
- package/dist/src/gateway/service.d.ts +119 -38
- package/dist/src/gateway/service.js +754 -798
- package/dist/src/gateway/service.js.map +1 -1
- package/dist/src/gateway/status-view.d.ts +119 -0
- package/dist/src/gateway/status-view.js +484 -0
- package/dist/src/gateway/status-view.js.map +1 -0
- package/dist/src/gateway/store.d.ts +106 -21
- package/dist/src/gateway/store.js +463 -537
- package/dist/src/gateway/store.js.map +1 -1
- package/dist/src/gateway/types.d.ts +64 -102
- package/dist/src/gateway/types.js +40 -86
- package/dist/src/gateway/types.js.map +1 -1
- package/docs/CONFIGURATION.md +174 -45
- package/docs/DELIVERY.md +11 -11
- package/docs/GATEWAY-ARCHITECTURE.md +292 -384
- package/package.json +4 -12
- package/skills/embassy-peer/SKILL.md +69 -92
- package/skills/embassy-peer/agents/openai.yaml +1 -1
- package/README.zh-CN.md +0 -275
- package/assets/live-dashboard/app.css +0 -1619
- package/assets/vendor/react/LICENSE +0 -21
- package/assets/vendor/react/react-dom.production.min.js +0 -267
- package/assets/vendor/react/react.production.min.js +0 -31
- package/dist/src/gateway/acp-client.d.ts +0 -110
- package/dist/src/gateway/acp-client.js +0 -407
- package/dist/src/gateway/acp-client.js.map +0 -1
- package/dist/src/gateway/acp-provider.d.ts +0 -66
- package/dist/src/gateway/acp-provider.js +0 -275
- package/dist/src/gateway/acp-provider.js.map +0 -1
- package/dist/src/gateway/claude-helper-client.d.ts +0 -2
- package/dist/src/gateway/claude-helper-client.js +0 -2
- package/dist/src/gateway/claude-helper-client.js.map +0 -1
- package/dist/src/gateway/cli-copy.d.ts +0 -8
- package/dist/src/gateway/cli-copy.en.d.ts +0 -22
- package/dist/src/gateway/cli-copy.en.js +0 -62
- package/dist/src/gateway/cli-copy.en.js.map +0 -1
- package/dist/src/gateway/cli-copy.js +0 -27
- package/dist/src/gateway/cli-copy.js.map +0 -1
- package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -22
- package/dist/src/gateway/cli-copy.zh-CN.js +0 -62
- package/dist/src/gateway/cli-copy.zh-CN.js.map +0 -1
- package/dist/src/gateway/codex-doctor.d.ts +0 -36
- package/dist/src/gateway/codex-doctor.js +0 -127
- package/dist/src/gateway/codex-doctor.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.d.ts +0 -7
- package/dist/src/gateway/dashboard-copy.en.d.ts +0 -504
- package/dist/src/gateway/dashboard-copy.en.js +0 -505
- package/dist/src/gateway/dashboard-copy.en.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.js +0 -514
- package/dist/src/gateway/dashboard-copy.js.map +0 -1
- package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +0 -504
- package/dist/src/gateway/dashboard-copy.zh-CN.js +0 -505
- package/dist/src/gateway/dashboard-copy.zh-CN.js.map +0 -1
- package/dist/src/gateway/dashboard-model.d.ts +0 -343
- package/dist/src/gateway/dashboard-model.js +0 -1061
- package/dist/src/gateway/dashboard-model.js.map +0 -1
- package/dist/src/gateway/dashboard.d.ts +0 -20
- package/dist/src/gateway/dashboard.js +0 -874
- package/dist/src/gateway/dashboard.js.map +0 -1
- package/dist/src/gateway/deepseek-detect.d.ts +0 -14
- package/dist/src/gateway/deepseek-detect.js +0 -41
- package/dist/src/gateway/deepseek-detect.js.map +0 -1
- package/dist/src/gateway/live-dashboard-app/app.js +0 -2385
- package/dist/src/gateway/live-dashboard-assets.d.ts +0 -10
- package/dist/src/gateway/live-dashboard-assets.js +0 -74
- package/dist/src/gateway/live-dashboard-assets.js.map +0 -1
- package/dist/src/gateway/live-dashboard-command.d.ts +0 -60
- package/dist/src/gateway/live-dashboard-command.js +0 -334
- package/dist/src/gateway/live-dashboard-command.js.map +0 -1
- package/dist/src/gateway/live-dashboard-http.d.ts +0 -39
- package/dist/src/gateway/live-dashboard-http.js +0 -383
- package/dist/src/gateway/live-dashboard-http.js.map +0 -1
- package/dist/src/gateway/live-dashboard-protocol.d.ts +0 -34
- package/dist/src/gateway/live-dashboard-protocol.js +0 -114
- package/dist/src/gateway/live-dashboard-protocol.js.map +0 -1
- package/dist/src/gateway/live-dashboard-server.d.ts +0 -33
- package/dist/src/gateway/live-dashboard-server.js +0 -144
- package/dist/src/gateway/live-dashboard-server.js.map +0 -1
- package/dist/src/gateway/live-dashboard-stream.d.ts +0 -46
- package/dist/src/gateway/live-dashboard-stream.js +0 -234
- package/dist/src/gateway/live-dashboard-stream.js.map +0 -1
- package/dist/src/gateway/live-dashboard.d.ts +0 -28
- package/dist/src/gateway/live-dashboard.js +0 -154
- package/dist/src/gateway/live-dashboard.js.map +0 -1
- package/dist/src/gateway/locale.d.ts +0 -4
- package/dist/src/gateway/locale.js +0 -10
- package/dist/src/gateway/locale.js.map +0 -1
- package/dist/src/gateway/progress-watch-machine.d.ts +0 -45
- package/dist/src/gateway/progress-watch-machine.js +0 -70
- package/dist/src/gateway/progress-watch-machine.js.map +0 -1
- package/docs/CONFIGURATION.zh-CN.md +0 -97
- package/docs/DASHBOARD.md +0 -98
- package/docs/DASHBOARD.zh-CN.md +0 -49
- package/docs/DELIVERY.zh-CN.md +0 -55
|
@@ -5,12 +5,38 @@ import os from "node:os";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { BridgeError } from "../errors.js";
|
|
7
7
|
import { KeyedMutex } from "../mutex.js";
|
|
8
|
-
import {
|
|
8
|
+
import { peerRouteRef } from "./peer-protocol.js";
|
|
9
9
|
import { deliveryStates, directionId, gatewayActivityActions, gatewayActivityKinds, gatewayProviders, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, routeRegistrationModes } from "./types.js";
|
|
10
10
|
const STATE_MARKER = ".agent-embassy-state";
|
|
11
11
|
const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
|
|
12
12
|
const STATE_FILE = "gateway-state.json";
|
|
13
|
-
|
|
13
|
+
/** Static dashboard files a 2.x install may have left behind (emb-100 removed the feature; emb-106 sweeps the litter). */
|
|
14
|
+
const STALE_DASHBOARD_FILES = ["gateway-dashboard.html", "gateway-dashboard.zh-CN.html"];
|
|
15
|
+
/** 2.x wrote its dashboards via temp-file + rename; a crash mid-publish could leave one of these behind too. */
|
|
16
|
+
const STALE_DASHBOARD_TEMP_PATTERNS = [
|
|
17
|
+
/^\.gateway-dashboard\.html\.[^/]+\.tmp$/,
|
|
18
|
+
/^\.gateway-dashboard\.zh-CN\.html\.[^/]+\.tmp$/,
|
|
19
|
+
];
|
|
20
|
+
function isStaleDashboardArtifact(name) {
|
|
21
|
+
return STALE_DASHBOARD_FILES.includes(name) ||
|
|
22
|
+
STALE_DASHBOARD_TEMP_PATTERNS.some((pattern) => pattern.test(name));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Locks this store moved aside as stale, kept a week so a crash stays
|
|
26
|
+
* diagnosable. The recovery time is in the name, not in the file's mtime:
|
|
27
|
+
* `rename` carries the crashed lock's own timestamps across, so a broker that
|
|
28
|
+
* ran for a month would have its lock swept by the very boot that recovered it.
|
|
29
|
+
*/
|
|
30
|
+
const STALE_LOCK_PATTERN = /^\.gateway-controller\.lock\.stale-(\d{13,16})-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
31
|
+
const STALE_LOCK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
/** The recovery time encoded in a stale-lock name, or undefined if this is not one. */
|
|
33
|
+
function staleLockRecoveredAt(name) {
|
|
34
|
+
const found = STALE_LOCK_PATTERN.exec(name);
|
|
35
|
+
return found === null ? undefined : Number(found[1]);
|
|
36
|
+
}
|
|
37
|
+
/** The controller lock's file name; the CLI reads the same file for a pid. */
|
|
38
|
+
export const GATEWAY_CONTROLLER_LOCK_FILE = ".gateway-controller.lock";
|
|
39
|
+
const CONTROLLER_LOCK = GATEWAY_CONTROLLER_LOCK_FILE;
|
|
14
40
|
const MAX_MARKER_FILE_BYTES = 128;
|
|
15
41
|
const MAX_LOCK_FILE_BYTES = 4 * 1024;
|
|
16
42
|
export const GATEWAY_MAX_STATE_FILE_BYTES = 8 * 1024 * 1024;
|
|
@@ -55,6 +81,27 @@ function isNonNegativeInteger(value) {
|
|
|
55
81
|
function isPositiveInteger(value) {
|
|
56
82
|
return Number.isSafeInteger(value) && value > 0;
|
|
57
83
|
}
|
|
84
|
+
const LOCK_HOSTNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
|
|
85
|
+
/**
|
|
86
|
+
* A machine name bounded before it is compared or handed to a client hint.
|
|
87
|
+
* Both sides of the comparison pass through here, so a local hostname the
|
|
88
|
+
* pattern cannot represent (a name with a space, say) is never mistaken for
|
|
89
|
+
* a different machine — it is simply unverifiable on both sides.
|
|
90
|
+
*/
|
|
91
|
+
function boundedHostname(value) {
|
|
92
|
+
return typeof value === "string" && LOCK_HOSTNAME_PATTERN.test(value) ? value : undefined;
|
|
93
|
+
}
|
|
94
|
+
const lockUnverified = () => new BridgeError("GATEWAY_STATE_LOCK_UNVERIFIED", "The gateway state lock exists but cannot be read as a controller record.");
|
|
95
|
+
function defaultProcessLiveness(pid) {
|
|
96
|
+
try {
|
|
97
|
+
process.kill(pid, 0);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
// ESRCH is the only answer that means "gone"; EPERM means alive and not ours.
|
|
102
|
+
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
58
105
|
function isIsoTimestamp(value) {
|
|
59
106
|
return (typeof value === "string" &&
|
|
60
107
|
value.length >= 20 &&
|
|
@@ -113,79 +160,26 @@ function isRoute(value) {
|
|
|
113
160
|
isIsoTimestamp(value.updatedAt) &&
|
|
114
161
|
isCounters(value.counters));
|
|
115
162
|
}
|
|
116
|
-
function isConsentEndpoint(value) {
|
|
117
|
-
return (isObject(value) &&
|
|
118
|
-
hasOnlyKeys(value, ["alias", "provider", "registrationId"]) &&
|
|
119
|
-
typeof value.alias === "string" &&
|
|
120
|
-
ALIAS_PATTERN.test(value.alias) &&
|
|
121
|
-
isProvider(value.provider) &&
|
|
122
|
-
isPrivateToken(value.registrationId));
|
|
123
|
-
}
|
|
124
|
-
function compareConsentEndpoints(left, right) {
|
|
125
|
-
const providerOrder = gatewayProviders.indexOf(left.provider) -
|
|
126
|
-
gatewayProviders.indexOf(right.provider);
|
|
127
|
-
return providerOrder !== 0 ? providerOrder : left.alias.localeCompare(right.alias);
|
|
128
|
-
}
|
|
129
|
-
function canonicalConsentEndpoints(left, right) {
|
|
130
|
-
const endpoints = [
|
|
131
|
-
{
|
|
132
|
-
alias: left.alias, provider: left.binding.provider,
|
|
133
|
-
registrationId: left.binding.registrationId,
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
alias: right.alias, provider: right.binding.provider,
|
|
137
|
-
registrationId: right.binding.registrationId,
|
|
138
|
-
},
|
|
139
|
-
];
|
|
140
|
-
endpoints.sort(compareConsentEndpoints);
|
|
141
|
-
return endpoints;
|
|
142
|
-
}
|
|
143
|
-
function consentKey(endpoints) {
|
|
144
|
-
return endpoints
|
|
145
|
-
.map((endpoint) => [endpoint.provider, endpoint.alias, endpoint.registrationId].join("\0"))
|
|
146
|
-
.join("\0");
|
|
147
|
-
}
|
|
148
|
-
function edgeOwnerHost(endpoints) {
|
|
149
|
-
return endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0];
|
|
150
|
-
}
|
|
151
163
|
function peerMirrorRegistrationId(host, routeRef) {
|
|
152
164
|
return `reg_peer_${createHash("sha256").update(`${host}\0${routeRef}`).digest("base64url").slice(0, 32)}`;
|
|
153
165
|
}
|
|
154
|
-
function sameConsent(left, right) {
|
|
155
|
-
if (left === null || right === null)
|
|
156
|
-
return left === right;
|
|
157
|
-
return consentKey(left) === consentKey(right);
|
|
158
|
-
}
|
|
159
166
|
function routeModeMatchesHost(route, localHost) {
|
|
160
167
|
if (route.registrationMode === "federated_peer") {
|
|
161
|
-
|
|
168
|
+
// Without a known local host there is nothing to prove the mirror foreign
|
|
169
|
+
// against, so the check abstains rather than refusing. The store always
|
|
170
|
+
// supplies its configured host, so the shipped path always checks.
|
|
171
|
+
return localHost === undefined || route.binding.hostId !== localHost;
|
|
162
172
|
}
|
|
163
173
|
return route.binding.hostId === localHost && (route.binding.provider === "claude"
|
|
164
174
|
? route.registrationMode === "selected_live_peer"
|
|
165
175
|
: route.registrationMode === "explicit_opt_in");
|
|
166
176
|
}
|
|
167
|
-
function isConsentEdge(value) {
|
|
168
|
-
if (!isObject(value) ||
|
|
169
|
-
!hasOnlyKeys(value, ["endpoints", "createdAt", "updatedAt", "counters"]) ||
|
|
170
|
-
!Array.isArray(value.endpoints) ||
|
|
171
|
-
value.endpoints.length !== 2 ||
|
|
172
|
-
!value.endpoints.every(isConsentEndpoint)) {
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
const endpoints = value.endpoints;
|
|
176
|
-
return (compareConsentEndpoints(endpoints[0], endpoints[1]) < 0 &&
|
|
177
|
-
endpoints[0].alias !== endpoints[1].alias &&
|
|
178
|
-
isIsoTimestamp(value.createdAt) &&
|
|
179
|
-
isIsoTimestamp(value.updatedAt) &&
|
|
180
|
-
isCounters(value.counters));
|
|
181
|
-
}
|
|
182
177
|
function isPrepared(value) {
|
|
183
178
|
return (isObject(value) &&
|
|
184
179
|
hasOnlyKeys(value, ["kind", "bodyBytes", "bodySha256", "frameBytes", "sha256"]) &&
|
|
185
180
|
(value.kind === "claude_mailbox" ||
|
|
186
181
|
value.kind === "codex_turn_start" ||
|
|
187
182
|
value.kind === "codex_turn_steer" ||
|
|
188
|
-
value.kind === "acp_prompt" ||
|
|
189
183
|
value.kind === "peer_mailbox" ||
|
|
190
184
|
value.kind === "peer_handoff") &&
|
|
191
185
|
isPositiveInteger(value.bodyBytes) &&
|
|
@@ -202,12 +196,9 @@ function expectedPreparedKind(message, targetMode) {
|
|
|
202
196
|
const target = parseDirection(message.direction).targetProvider;
|
|
203
197
|
if (target === "claude")
|
|
204
198
|
return "claude_mailbox";
|
|
205
|
-
if (target === "codex") {
|
|
206
|
-
return message.steer === true ? "codex_turn_steer" : "codex_turn_start";
|
|
207
|
-
}
|
|
208
199
|
if (target === "peer")
|
|
209
200
|
return "peer_mailbox";
|
|
210
|
-
return "
|
|
201
|
+
return message.steer === true ? "codex_turn_steer" : "codex_turn_start";
|
|
211
202
|
}
|
|
212
203
|
function isAttemptAuthority(value) {
|
|
213
204
|
return (typeof value.attemptId === "string" &&
|
|
@@ -215,13 +206,7 @@ function isAttemptAuthority(value) {
|
|
|
215
206
|
isPositiveInteger(value.attemptCount) &&
|
|
216
207
|
typeof value.targetRegistrationId === "string" &&
|
|
217
208
|
isPrivateToken(value.targetRegistrationId) &&
|
|
218
|
-
(value.sourceRegistrationId
|
|
219
|
-
isPrivateToken(value.sourceRegistrationId)) &&
|
|
220
|
-
(value.consentEdge === null ||
|
|
221
|
-
(Array.isArray(value.consentEdge) &&
|
|
222
|
-
value.consentEdge.length === 2 &&
|
|
223
|
-
value.consentEdge.every(isConsentEndpoint) &&
|
|
224
|
-
compareConsentEndpoints(value.consentEdge[0], value.consentEdge[1]) < 0)));
|
|
209
|
+
isPrivateToken(value.sourceRegistrationId));
|
|
225
210
|
}
|
|
226
211
|
function isMessageState(value) {
|
|
227
212
|
if (!isObject(value) || typeof value.phase !== "string")
|
|
@@ -232,20 +217,20 @@ function isMessageState(value) {
|
|
|
232
217
|
}
|
|
233
218
|
if (value.phase === "reserved") {
|
|
234
219
|
return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
|
|
235
|
-
"sourceRegistrationId", "
|
|
220
|
+
"sourceRegistrationId", "reservedAt"]) &&
|
|
236
221
|
isAttemptAuthority(value) &&
|
|
237
222
|
isIsoTimestamp(value.reservedAt));
|
|
238
223
|
}
|
|
239
224
|
if (value.phase === "armed") {
|
|
240
225
|
return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
|
|
241
|
-
"sourceRegistrationId", "
|
|
226
|
+
"sourceRegistrationId", "armedAt", "prepared"]) &&
|
|
242
227
|
isAttemptAuthority(value) &&
|
|
243
228
|
isIsoTimestamp(value.armedAt) &&
|
|
244
229
|
isPrepared(value.prepared));
|
|
245
230
|
}
|
|
246
231
|
if (value.phase === "accepted") {
|
|
247
232
|
return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
|
|
248
|
-
"sourceRegistrationId", "
|
|
233
|
+
"sourceRegistrationId", "acceptedAt", "prepared", "lossOutcome"]) &&
|
|
249
234
|
isAttemptAuthority(value) &&
|
|
250
235
|
isIsoTimestamp(value.acceptedAt) &&
|
|
251
236
|
isPrepared(value.prepared) &&
|
|
@@ -270,7 +255,7 @@ function isMessage(value) {
|
|
|
270
255
|
if (!isObject(value) ||
|
|
271
256
|
!hasOnlyKeys(value, ["sequence", "messageId", "messageIdSuffix", "direction", "sourceAlias",
|
|
272
257
|
"targetAlias", "enqueuedAt", "deadlineAt", "bytes", "sourceRegistrationId",
|
|
273
|
-
"targetRegistrationId", "
|
|
258
|
+
"targetRegistrationId", "state"], ["conversationIdSuffix", "deliveryToken", "body", "steer"])) {
|
|
274
259
|
return false;
|
|
275
260
|
}
|
|
276
261
|
return (isPositiveInteger(value.sequence) &&
|
|
@@ -299,18 +284,10 @@ function isMessage(value) {
|
|
|
299
284
|
(value.deliveryToken === undefined ||
|
|
300
285
|
(typeof value.deliveryToken === "string" &&
|
|
301
286
|
DELIVERY_TOKEN_PATTERN.test(value.deliveryToken))) &&
|
|
302
|
-
(value.pair === undefined || value.pair === true) &&
|
|
303
|
-
(value.transientTarget === undefined || value.transientTarget === true) &&
|
|
304
287
|
(value.steer === undefined || value.steer === true) &&
|
|
305
|
-
(value.sourceRegistrationId
|
|
306
|
-
isPrivateToken(value.sourceRegistrationId)) &&
|
|
288
|
+
isPrivateToken(value.sourceRegistrationId) &&
|
|
307
289
|
(value.targetRegistrationId === null ||
|
|
308
290
|
isPrivateToken(value.targetRegistrationId)) &&
|
|
309
|
-
(value.consentEdge === null ||
|
|
310
|
-
(Array.isArray(value.consentEdge) &&
|
|
311
|
-
value.consentEdge.length === 2 &&
|
|
312
|
-
value.consentEdge.every(isConsentEndpoint) &&
|
|
313
|
-
compareConsentEndpoints(value.consentEdge[0], value.consentEdge[1]) < 0)) &&
|
|
314
291
|
isMessageState(value.state) &&
|
|
315
292
|
(value.state.phase === "terminal" ||
|
|
316
293
|
value.state.attemptCount <=
|
|
@@ -324,7 +301,7 @@ function isMessage(value) {
|
|
|
324
301
|
function isDedupe(value) {
|
|
325
302
|
return (isObject(value) &&
|
|
326
303
|
hasOnlyKeys(value, ["fingerprint", "messageIdSuffix", "sourceAlias", "targetAlias", "direction",
|
|
327
|
-
"firstSeenAt", "expiresAt"], ["conversationIdSuffix"
|
|
304
|
+
"firstSeenAt", "expiresAt"], ["conversationIdSuffix"]) &&
|
|
328
305
|
typeof value.fingerprint === "string" &&
|
|
329
306
|
FINGERPRINT_PATTERN.test(value.fingerprint) &&
|
|
330
307
|
typeof value.messageIdSuffix === "string" &&
|
|
@@ -338,8 +315,7 @@ function isDedupe(value) {
|
|
|
338
315
|
isIsoTimestamp(value.expiresAt) &&
|
|
339
316
|
(value.conversationIdSuffix === undefined ||
|
|
340
317
|
(typeof value.conversationIdSuffix === "string" &&
|
|
341
|
-
CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix)))
|
|
342
|
-
(value.pair === undefined || value.pair === true));
|
|
318
|
+
CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))));
|
|
343
319
|
}
|
|
344
320
|
function isAccounting(value) {
|
|
345
321
|
return (isObject(value) &&
|
|
@@ -402,11 +378,20 @@ function isMessageActivity(value) {
|
|
|
402
378
|
value.type === "message_activity" &&
|
|
403
379
|
isNormalizedEvent(value.event));
|
|
404
380
|
}
|
|
405
|
-
|
|
381
|
+
/**
|
|
382
|
+
* `configuredHost` is the broker's own host identity, known at construction and
|
|
383
|
+
* always supplied on the shipped path. Deriving the local host from the routes
|
|
384
|
+
* alone is not sufficient: a federation node whose only routes are mirrors has
|
|
385
|
+
* no local route to derive from, and a state file it just wrote would fail its
|
|
386
|
+
* own validator on the next persist.
|
|
387
|
+
*/
|
|
388
|
+
export function isGatewayPersistedStateV5(value, configuredHost) {
|
|
406
389
|
if (!isObject(value) ||
|
|
390
|
+
// Exact keys: a file that still carries the retired `consentEdges` key is
|
|
391
|
+
// refused here as a corrupt document; the documented recovery is a reset.
|
|
407
392
|
!hasOnlyKeys(value, ["schemaVersion", "commit", "createdAt", "updatedAt", "eventSequence",
|
|
408
|
-
"routes", "
|
|
409
|
-
value.schemaVersion !==
|
|
393
|
+
"routes", "messages", "dedupe", "rateBuckets", "activity", "accounting"]) ||
|
|
394
|
+
value.schemaVersion !== 5 ||
|
|
410
395
|
!isObject(value.commit) ||
|
|
411
396
|
!hasOnlyKeys(value.commit, ["sequence", "id"]) ||
|
|
412
397
|
!isNonNegativeInteger(value.commit.sequence) ||
|
|
@@ -416,8 +401,6 @@ export function isGatewayPersistedStateV4(value) {
|
|
|
416
401
|
!isNonNegativeInteger(value.eventSequence) ||
|
|
417
402
|
!Array.isArray(value.routes) ||
|
|
418
403
|
!value.routes.every(isRoute) ||
|
|
419
|
-
!Array.isArray(value.consentEdges) ||
|
|
420
|
-
!value.consentEdges.every(isConsentEdge) ||
|
|
421
404
|
!Array.isArray(value.messages) ||
|
|
422
405
|
!value.messages.every(isMessage) ||
|
|
423
406
|
!Array.isArray(value.dedupe) ||
|
|
@@ -436,7 +419,6 @@ export function isGatewayPersistedStateV4(value) {
|
|
|
436
419
|
const routesByAlias = new Map(state.routes.map((route) => [route.alias, route]));
|
|
437
420
|
const registrationIds = state.routes.map((route) => route.binding.registrationId);
|
|
438
421
|
const routeTargets = state.routes.map((route) => [route.binding.provider, route.binding.hostId, route.binding.routeHandle].join("\0"));
|
|
439
|
-
const edgeKeys = state.consentEdges.map((edge) => consentKey(edge.endpoints));
|
|
440
422
|
const messages = state.messages;
|
|
441
423
|
const activitySequences = state.activity.map((entry) => entry.event.sequence);
|
|
442
424
|
const active = messages.filter((message) => message.state.phase !== "terminal");
|
|
@@ -449,7 +431,6 @@ export function isGatewayPersistedStateV4(value) {
|
|
|
449
431
|
if (routesByAlias.size !== state.routes.length ||
|
|
450
432
|
new Set(registrationIds).size !== registrationIds.length ||
|
|
451
433
|
new Set(routeTargets).size !== routeTargets.length ||
|
|
452
|
-
new Set(edgeKeys).size !== edgeKeys.length ||
|
|
453
434
|
new Set(messages.map((message) => message.messageId)).size !== messages.length ||
|
|
454
435
|
new Set(tokens).size !== tokens.length ||
|
|
455
436
|
new Set(attemptIds).size !== attempts.length ||
|
|
@@ -462,34 +443,15 @@ export function isGatewayPersistedStateV4(value) {
|
|
|
462
443
|
new Set(state.rateBuckets.map((entry) => entry.sourceAlias)).size !== state.rateBuckets.length) {
|
|
463
444
|
return false;
|
|
464
445
|
}
|
|
465
|
-
for (const message of messages) {
|
|
466
|
-
const direction = parseDirection(message.direction);
|
|
467
|
-
if ((message.pair === true) !== (message.consentEdge !== null) ||
|
|
468
|
-
(message.sourceRegistrationId === null &&
|
|
469
|
-
(direction.sourceProvider !== "claude" || message.pair === true)) ||
|
|
470
|
-
(message.transientTarget === true &&
|
|
471
|
-
(direction.targetProvider !== "claude" ||
|
|
472
|
-
message.pair === true ||
|
|
473
|
-
message.sourceRegistrationId === null))) {
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
if (message.consentEdge !== null) {
|
|
477
|
-
const source = message.consentEdge.find((endpoint) => endpoint.alias === message.sourceAlias);
|
|
478
|
-
const target = message.consentEdge.find((endpoint) => endpoint.alias === message.targetAlias);
|
|
479
|
-
if (source?.provider !== direction.sourceProvider ||
|
|
480
|
-
target?.provider !== direction.targetProvider ||
|
|
481
|
-
source.registrationId !== message.sourceRegistrationId ||
|
|
482
|
-
target.registrationId !== message.targetRegistrationId) {
|
|
483
|
-
return false;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
446
|
const localHosts = new Set(state.routes
|
|
488
447
|
.filter((route) => route.registrationMode !== "federated_peer")
|
|
489
448
|
.map((route) => route.binding.hostId));
|
|
490
449
|
if (localHosts.size > 1)
|
|
491
450
|
return false;
|
|
492
|
-
|
|
451
|
+
// The configured host is authority; a local route's own hostId is checked
|
|
452
|
+
// against it in the loop below, so the derived host is only the fallback for
|
|
453
|
+
// a caller that has no configured identity to offer.
|
|
454
|
+
const localHost = configuredHost ?? [...localHosts][0];
|
|
493
455
|
for (const route of state.routes) {
|
|
494
456
|
if (!route.alias.endsWith(`@${route.binding.hostId}`) ||
|
|
495
457
|
!routeModeMatchesHost(route, localHost)) {
|
|
@@ -499,70 +461,24 @@ export function isGatewayPersistedStateV4(value) {
|
|
|
499
461
|
if (prefix !== undefined && !route.alias.startsWith(prefix))
|
|
500
462
|
return false;
|
|
501
463
|
}
|
|
502
|
-
for (const edge of state.consentEdges) {
|
|
503
|
-
const [leftEndpoint, rightEndpoint] = edge.endpoints;
|
|
504
|
-
const left = routesByAlias.get(leftEndpoint.alias);
|
|
505
|
-
const right = routesByAlias.get(rightEndpoint.alias);
|
|
506
|
-
if (left?.binding.registrationId !== leftEndpoint.registrationId ||
|
|
507
|
-
right?.binding.registrationId !== rightEndpoint.registrationId ||
|
|
508
|
-
left.binding.provider !== leftEndpoint.provider ||
|
|
509
|
-
right.binding.provider !== rightEndpoint.provider ||
|
|
510
|
-
(left.binding.hostId === right.binding.hostId &&
|
|
511
|
-
left.binding.provider === right.binding.provider)) {
|
|
512
|
-
return false;
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
464
|
for (const message of active) {
|
|
516
465
|
const target = routesByAlias.get(message.targetAlias);
|
|
517
466
|
const source = routesByAlias.get(message.sourceAlias);
|
|
518
467
|
const direction = parseDirection(message.direction);
|
|
519
468
|
if (message.targetRegistrationId === null ||
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (message.sourceRegistrationId !== null) {
|
|
527
|
-
if (source?.binding.registrationId !== message.sourceRegistrationId ||
|
|
528
|
-
source.binding.provider !== direction.sourceProvider ||
|
|
529
|
-
(target !== undefined && source.binding.hostId === target.binding.hostId &&
|
|
530
|
-
source.binding.provider === target.binding.provider)) {
|
|
531
|
-
return false;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
else if (direction.sourceProvider !== "claude" ||
|
|
535
|
-
target === undefined ||
|
|
536
|
-
aliasHost(message.sourceAlias) !== target.binding.hostId) {
|
|
537
|
-
return false;
|
|
538
|
-
}
|
|
539
|
-
if (message.transientTarget === true) {
|
|
540
|
-
if (message.pair === true ||
|
|
541
|
-
message.consentEdge !== null ||
|
|
542
|
-
message.sourceRegistrationId === null ||
|
|
543
|
-
source === undefined ||
|
|
544
|
-
aliasHost(message.targetAlias) !== source.binding.hostId) {
|
|
545
|
-
return false;
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
if (message.pair === true) {
|
|
549
|
-
if (message.consentEdge === null ||
|
|
550
|
-
source === undefined ||
|
|
551
|
-
target === undefined ||
|
|
552
|
-
!sameConsent(message.consentEdge, canonicalConsentEndpoints(source, target)) ||
|
|
553
|
-
!state.consentEdges.some((edge) => sameConsent(edge.endpoints, message.consentEdge))) {
|
|
554
|
-
return false;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
else if (message.consentEdge !== null) {
|
|
469
|
+
target?.binding.registrationId !== message.targetRegistrationId ||
|
|
470
|
+
target.binding.provider !== direction.targetProvider ||
|
|
471
|
+
source?.binding.registrationId !== message.sourceRegistrationId ||
|
|
472
|
+
source.binding.provider !== direction.sourceProvider ||
|
|
473
|
+
(source.binding.hostId === target.binding.hostId &&
|
|
474
|
+
source.binding.provider === target.binding.provider)) {
|
|
558
475
|
return false;
|
|
559
476
|
}
|
|
560
477
|
if ((message.state.phase === "reserved" ||
|
|
561
478
|
message.state.phase === "armed" ||
|
|
562
479
|
message.state.phase === "accepted") &&
|
|
563
480
|
(message.state.sourceRegistrationId !== message.sourceRegistrationId ||
|
|
564
|
-
message.state.targetRegistrationId !== message.targetRegistrationId
|
|
565
|
-
!sameConsent(message.state.consentEdge, message.consentEdge))) {
|
|
481
|
+
message.state.targetRegistrationId !== message.targetRegistrationId)) {
|
|
566
482
|
return false;
|
|
567
483
|
}
|
|
568
484
|
if ((message.state.phase === "armed" ||
|
|
@@ -615,9 +531,6 @@ function sameBinding(left, right) {
|
|
|
615
531
|
left.routeHandle === right.routeHandle &&
|
|
616
532
|
left.registrationId === right.registrationId);
|
|
617
533
|
}
|
|
618
|
-
function aliasHost(alias) {
|
|
619
|
-
return alias.slice(alias.lastIndexOf("@") + 1);
|
|
620
|
-
}
|
|
621
534
|
async function assertNoSymlinkComponents(candidate) {
|
|
622
535
|
let cursor = path.resolve(candidate);
|
|
623
536
|
while (true) {
|
|
@@ -668,7 +581,20 @@ export class GatewayStore {
|
|
|
668
581
|
state;
|
|
669
582
|
lockHandle;
|
|
670
583
|
lockToken;
|
|
584
|
+
isProcessAlive;
|
|
585
|
+
hostname;
|
|
671
586
|
persistenceDeferred = false;
|
|
587
|
+
/**
|
|
588
|
+
* Registration ids that must never reach the durable state document. They
|
|
589
|
+
* are ordinary rows in the in-memory state — admission, dispatch, queueing
|
|
590
|
+
* and settlement all see them — and are projected out at the one place that
|
|
591
|
+
* serializes, together with every row that names one. When such a
|
|
592
|
+
* registration is removed, the same rows leave the live state in the same
|
|
593
|
+
* mutate, before the id leaves this set; a rolled-back mutate restores the
|
|
594
|
+
* set with the state. Nothing here is written down, so a restart reclaims
|
|
595
|
+
* them by construction.
|
|
596
|
+
*/
|
|
597
|
+
ephemeralRegistrations = new Set();
|
|
672
598
|
constructor(config, dependencies = {}) {
|
|
673
599
|
this.config = config;
|
|
674
600
|
this.rootDir = path.resolve(config.stateDir);
|
|
@@ -676,6 +602,8 @@ export class GatewayStore {
|
|
|
676
602
|
this.randomId = dependencies.randomId ?? randomUUID;
|
|
677
603
|
this.renameStateFile = dependencies.renameStateFile ?? rename;
|
|
678
604
|
this.afterStateFileRename = dependencies.afterStateFileRename;
|
|
605
|
+
this.isProcessAlive = dependencies.isProcessAlive ?? defaultProcessLiveness;
|
|
606
|
+
this.hostname = dependencies.hostname ?? (() => os.hostname());
|
|
679
607
|
}
|
|
680
608
|
get stateFilePath() {
|
|
681
609
|
return path.join(this.rootDir, STATE_FILE);
|
|
@@ -690,15 +618,16 @@ export class GatewayStore {
|
|
|
690
618
|
const now = this.now();
|
|
691
619
|
const loaded = await this.loadStateFile();
|
|
692
620
|
this.state = loaded ?? {
|
|
693
|
-
schemaVersion:
|
|
621
|
+
schemaVersion: 5,
|
|
694
622
|
commit: { sequence: 0, id: this.randomId() },
|
|
695
623
|
createdAt: now.toISOString(), updatedAt: now.toISOString(),
|
|
696
624
|
eventSequence: 0, routes: [],
|
|
697
|
-
|
|
625
|
+
messages: [],
|
|
698
626
|
dedupe: [], rateBuckets: [],
|
|
699
627
|
activity: [], accounting: emptyAccounting(),
|
|
700
628
|
};
|
|
701
629
|
this.assertConfiguredBounds(this.state);
|
|
630
|
+
await this.removeStateDirectoryLitter(this.rootDir);
|
|
702
631
|
this.recoverAfterRestart(now);
|
|
703
632
|
this.prune(this.state, now);
|
|
704
633
|
if (loaded !== undefined) {
|
|
@@ -744,9 +673,6 @@ export class GatewayStore {
|
|
|
744
673
|
async listLogicalRoutes() {
|
|
745
674
|
return this.inspectPrivateRoutes();
|
|
746
675
|
}
|
|
747
|
-
async inspectPrivateConsentEdges() {
|
|
748
|
-
return this.read((state) => structuredClone(state.consentEdges));
|
|
749
|
-
}
|
|
750
676
|
async inspectPrivateRoute(alias) {
|
|
751
677
|
return this.read((state) => {
|
|
752
678
|
const route = state.routes.find((candidate) => candidate.alias === alias);
|
|
@@ -762,8 +688,7 @@ export class GatewayStore {
|
|
|
762
688
|
return this.mutate((state, now) => {
|
|
763
689
|
const localHost = this.config.hostId;
|
|
764
690
|
if (!this.config.allowedHosts.includes(peerHost) || peerHost === localHost ||
|
|
765
|
-
!catalog.complete || catalog.truncated || catalog.routes.some((route) => route.host !== peerHost)
|
|
766
|
-
catalog.consentEdges.some((edge) => edge.ownerHost !== peerHost)) {
|
|
691
|
+
!catalog.complete || catalog.truncated || catalog.routes.some((route) => route.host !== peerHost)) {
|
|
767
692
|
throw new BridgeError("INVALID_PEER_CATALOG", "The peer catalog is not an exact complete projection for this configured host.");
|
|
768
693
|
}
|
|
769
694
|
const desired = new Map(catalog.routes.map((route) => [route.ref, route]));
|
|
@@ -799,31 +724,6 @@ export class GatewayStore {
|
|
|
799
724
|
route.enabled = row.enabled;
|
|
800
725
|
route.updatedAt = now.toISOString();
|
|
801
726
|
}
|
|
802
|
-
const peerOwned = (edge) => edgeOwnerHost(edge.endpoints) === peerHost &&
|
|
803
|
-
edge.endpoints.some((endpoint) => endpoint.alias.endsWith(`@${peerHost}`));
|
|
804
|
-
const desiredEdges = [];
|
|
805
|
-
for (const edge of catalog.consentEdges) {
|
|
806
|
-
const resolved = edge.endpoints.map((endpoint) => state.routes.find((route) => route.alias === endpoint.alias && route.binding.provider === endpoint.provider &&
|
|
807
|
-
(endpoint.host === peerHost ? route.registrationMode === "federated_peer" && route.binding.hostId === peerHost &&
|
|
808
|
-
route.binding.routeHandle === endpoint.routeRef : endpoint.host === localHost &&
|
|
809
|
-
route.registrationMode !== "federated_peer" && route.binding.hostId === localHost &&
|
|
810
|
-
peerRouteRef(localHost, route.binding.registrationId) === endpoint.routeRef)));
|
|
811
|
-
if (resolved[0] === undefined || resolved[1] === undefined || edge.ref !== peerEdgeRef(edge.endpoints))
|
|
812
|
-
throw new BridgeError("INVALID_PEER_CATALOG", "A peer consent edge does not match current endpoint authority.");
|
|
813
|
-
desiredEdges.push(canonicalConsentEndpoints(resolved[0], resolved[1]));
|
|
814
|
-
}
|
|
815
|
-
const removedEdges = state.consentEdges.filter((edge) => peerOwned(edge) &&
|
|
816
|
-
!desiredEdges.some((candidate) => sameConsent(edge.endpoints, candidate)));
|
|
817
|
-
for (const edge of removedEdges)
|
|
818
|
-
settlements.push(...this.terminalizeConsentEdge(state, edge, now));
|
|
819
|
-
state.consentEdges = state.consentEdges.filter((edge) => !removedEdges.includes(edge));
|
|
820
|
-
for (const endpoints of desiredEdges) {
|
|
821
|
-
if (state.consentEdges.some((edge) => sameConsent(edge.endpoints, endpoints)))
|
|
822
|
-
continue;
|
|
823
|
-
if (state.consentEdges.length >= this.config.limits.maxConsentEdges)
|
|
824
|
-
throw new BridgeError("CONSENT_EDGE_CAPACITY_REACHED", "The bounded consent-edge inventory is full.", true);
|
|
825
|
-
state.consentEdges.push({ endpoints, createdAt: now.toISOString(), updatedAt: now.toISOString(), counters: emptyCounters() });
|
|
826
|
-
}
|
|
827
727
|
return { settlements, routes: state.routes.filter((route) => route.registrationMode === "federated_peer" &&
|
|
828
728
|
route.binding.hostId === peerHost).map((route) => ({ alias: route.alias, binding: { ...route.binding },
|
|
829
729
|
registrationMode: route.registrationMode, enabled: route.enabled })) };
|
|
@@ -832,9 +732,11 @@ export class GatewayStore {
|
|
|
832
732
|
async enqueuePeerHandoff(peerHost, handoff) {
|
|
833
733
|
return this.mutate((state, now) => {
|
|
834
734
|
const localHost = this.config.hostId;
|
|
735
|
+
// A handoff is authorized by the sending host's nodes.json membership
|
|
736
|
+
// (checked by the service before this call) plus exact alias addressing
|
|
737
|
+
// to a mirrored source and a local, enabled target.
|
|
835
738
|
if (!this.config.allowedHosts.includes(peerHost) || peerHost === localHost || handoff.source.host !== peerHost ||
|
|
836
|
-
handoff.target.host !== localHost
|
|
837
|
-
handoff.edgeRef !== peerEdgeRef([handoff.source, handoff.target]))
|
|
739
|
+
handoff.target.host !== localHost)
|
|
838
740
|
throw new BridgeError("INVALID_PEER_HANDOFF", "The peer handoff does not match this configured direct link.");
|
|
839
741
|
const source = state.routes.find((route) => route.registrationMode === "federated_peer" && route.binding.hostId === peerHost &&
|
|
840
742
|
route.alias === handoff.source.alias && route.binding.provider === handoff.source.provider && route.binding.routeHandle === handoff.source.routeRef);
|
|
@@ -843,21 +745,11 @@ export class GatewayStore {
|
|
|
843
745
|
peerRouteRef(localHost, route.binding.registrationId) === handoff.target.routeRef && route.enabled);
|
|
844
746
|
if (source === undefined || target === undefined)
|
|
845
747
|
throw new BridgeError("ROUTE_UNREGISTERED", "The peer handoff endpoint is no longer current.");
|
|
846
|
-
const endpoints = canonicalConsentEndpoints(source, target);
|
|
847
|
-
let edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, endpoints));
|
|
848
|
-
if (edge === undefined && handoff.edgeOwnerHost === peerHost) {
|
|
849
|
-
if (state.consentEdges.length >= this.config.limits.maxConsentEdges)
|
|
850
|
-
throw new BridgeError("CONSENT_EDGE_CAPACITY_REACHED", "The bounded consent-edge inventory is full.", true);
|
|
851
|
-
edge = { endpoints, createdAt: now.toISOString(), updatedAt: now.toISOString(), counters: emptyCounters() };
|
|
852
|
-
state.consentEdges.push(edge);
|
|
853
|
-
}
|
|
854
|
-
if (edge === undefined)
|
|
855
|
-
throw new BridgeError("SENDER_NOT_PAIRED", "The peer handoff lacks current exact consent.");
|
|
856
748
|
return this.enqueueResolved(state, now, { body: handoff.body, dedupeKey: `${peerHost}:${handoff.originMessageId}`,
|
|
857
749
|
deadlineAt: handoff.deadlineAt, ...(handoff.conversationCorrelation === undefined ? {} :
|
|
858
750
|
{ conversationIdSuffix: handoff.conversationCorrelation }), ...(handoff.steer === true ? { steer: true } : {}) }, { sourceAlias: source.alias, targetAlias: target.alias, direction: directionId(source.binding.provider, target.binding.provider),
|
|
859
751
|
sourceRegistrationId: source.binding.registrationId, targetRegistrationId: target.binding.registrationId,
|
|
860
|
-
|
|
752
|
+
exposeDeliveryToken: false });
|
|
861
753
|
});
|
|
862
754
|
}
|
|
863
755
|
async registerRoute(input) {
|
|
@@ -883,8 +775,60 @@ export class GatewayStore {
|
|
|
883
775
|
throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
|
|
884
776
|
}
|
|
885
777
|
state.routes.push(routeRecord(input, now));
|
|
778
|
+
if (input.ephemeral === true)
|
|
779
|
+
this.ephemeralRegistrations.add(input.binding.registrationId);
|
|
886
780
|
});
|
|
887
781
|
}
|
|
782
|
+
/** Whether this registration is memory-only; false for every durable route. */
|
|
783
|
+
isEphemeralRegistration(registrationId) {
|
|
784
|
+
return this.ephemeralRegistrations.has(registrationId);
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Everything an ephemeral registration owns, taken out of `state`: the route
|
|
788
|
+
* itself, every message that names it as source or target, the dedupe
|
|
789
|
+
* fingerprints and rate bucket keyed by its alias, the journal rows —
|
|
790
|
+
* message activity and runtime activity — that name the alias, and the
|
|
791
|
+
* queued-bytes total those messages contributed. Pure: the caller decides
|
|
792
|
+
* whether this becomes the written projection or the live state.
|
|
793
|
+
*/
|
|
794
|
+
ephemeralRowsRemoved(state, ids, aliases) {
|
|
795
|
+
const kept = (message) => !ids.has(message.sourceRegistrationId) &&
|
|
796
|
+
(message.targetRegistrationId === null || !ids.has(message.targetRegistrationId));
|
|
797
|
+
const names = (row) => aliases.has(row.sourceAlias) || aliases.has(row.targetAlias);
|
|
798
|
+
const droppedQueuedBytes = state.messages
|
|
799
|
+
.filter((message) => !kept(message) && message.state.phase === "queued")
|
|
800
|
+
.reduce((total, message) => total + message.bytes, 0);
|
|
801
|
+
return {
|
|
802
|
+
routes: state.routes.filter((route) => !ids.has(route.binding.registrationId)),
|
|
803
|
+
messages: state.messages.filter(kept),
|
|
804
|
+
dedupe: state.dedupe.filter((row) => !names(row)),
|
|
805
|
+
rateBuckets: state.rateBuckets.filter((row) => !aliases.has(row.sourceAlias)),
|
|
806
|
+
activity: state.activity.filter((entry) => entry.type === "message_activity"
|
|
807
|
+
? !names(entry.event)
|
|
808
|
+
: !entry.event.aliases.some((alias) => aliases.has(alias))),
|
|
809
|
+
accounting: { ...state.accounting,
|
|
810
|
+
queuedBytes: state.accounting.queuedBytes - droppedQueuedBytes },
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* The document actually written. While an ephemeral registration lives, its
|
|
815
|
+
* rows are projected out here, so the written state stays internally
|
|
816
|
+
* consistent under the strict loader — which refuses a state whose active
|
|
817
|
+
* message points at a route it cannot find, or whose queued total disagrees
|
|
818
|
+
* with its queued rows. Both refusals are exactly right, and neither must
|
|
819
|
+
* ever be reachable by a document we wrote. When the registration is
|
|
820
|
+
* removed, `removeRegistrationMetadata` takes the same rows out of the live
|
|
821
|
+
* state in the same mutate. Attributable rows and bodies are removed;
|
|
822
|
+
* aggregate counters may remain advanced.
|
|
823
|
+
*/
|
|
824
|
+
persistedProjection(state) {
|
|
825
|
+
if (this.ephemeralRegistrations.size === 0)
|
|
826
|
+
return state;
|
|
827
|
+
const dropped = state.routes.filter((route) => this.ephemeralRegistrations.has(route.binding.registrationId));
|
|
828
|
+
if (dropped.length === 0)
|
|
829
|
+
return state;
|
|
830
|
+
return { ...state, ...this.ephemeralRowsRemoved(state, new Set(dropped.map((route) => route.binding.registrationId)), new Set(dropped.map((route) => route.alias))) };
|
|
831
|
+
}
|
|
888
832
|
async removeOwnedRouteAtomic(input) {
|
|
889
833
|
return this.mutate((state, now) => {
|
|
890
834
|
if (!ALIAS_PATTERN.test(input.alias) || !isLogicalBinding(input.binding)) {
|
|
@@ -894,19 +838,17 @@ export class GatewayStore {
|
|
|
894
838
|
if (route === undefined || !sameBinding(route.binding, input.binding)) {
|
|
895
839
|
return { removed: false, settlements: [] };
|
|
896
840
|
}
|
|
897
|
-
if (
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
841
|
+
if (route.registrationMode === "federated_peer") {
|
|
842
|
+
throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node; only catalog reconciliation may retire it.");
|
|
843
|
+
}
|
|
844
|
+
if (input.activity !== undefined && input.activity.action !== "route_retired" && route.binding.provider !== "codex") {
|
|
845
|
+
throw new BridgeError("INVALID_ROUTE_BINDING", "Only exact Codex unregister has public activity.");
|
|
901
846
|
}
|
|
902
847
|
const settlements = this.terminalizeRegistration(state, route.binding.registrationId, now);
|
|
903
848
|
this.removeRegistrationMetadata(state, route);
|
|
904
849
|
if (input.activity !== undefined) {
|
|
905
850
|
this.appendRuntimeActivity(state, now, {
|
|
906
|
-
kind:
|
|
907
|
-
action: route.binding.provider === "claude"
|
|
908
|
-
? "claude_unselected"
|
|
909
|
-
: "codex_unregistered",
|
|
851
|
+
kind: "registration", action: input.activity.action ?? "codex_unregistered",
|
|
910
852
|
outcome: "accepted", aliases: [route.alias],
|
|
911
853
|
operatorAction: input.activity.operatorAction,
|
|
912
854
|
});
|
|
@@ -969,90 +911,129 @@ export class GatewayStore {
|
|
|
969
911
|
return { replaced: true, idempotent: false, settlements };
|
|
970
912
|
});
|
|
971
913
|
}
|
|
972
|
-
|
|
914
|
+
/**
|
|
915
|
+
* Installs the route of a discovered Claude session on its first send, or
|
|
916
|
+
* brings an existing route up to date with discovery. The (host, session
|
|
917
|
+
* UUID) pair is the identity: a session already bound keeps its
|
|
918
|
+
* registration (and its in-flight conversations) and is renamed if its
|
|
919
|
+
* current name changed; a route whose alias now names a different session
|
|
920
|
+
* is displaced, its work settled `cancelled/ENDPOINT_RETIRED`. Both
|
|
921
|
+
* outcomes are journaled so `embassy status` can show them. Installation
|
|
922
|
+
* never creates a permission: same-UID, same-host addressing already is
|
|
923
|
+
* the permission, and the service's alias-collision fence has already run.
|
|
924
|
+
*/
|
|
925
|
+
async installClaudeRoute(replacement) {
|
|
973
926
|
return this.mutate((state, now) => {
|
|
974
927
|
this.validateRouteInput(replacement);
|
|
975
928
|
if (replacement.binding.provider !== "claude") {
|
|
976
|
-
throw new BridgeError("INVALID_ROUTE_BINDING", "
|
|
929
|
+
throw new BridgeError("INVALID_ROUTE_BINDING", "Only a Claude logical route installs on first send.");
|
|
977
930
|
}
|
|
978
931
|
const byAlias = state.routes.find((route) => route.alias === replacement.alias);
|
|
979
932
|
const byRegistration = state.routes.find((route) => route.binding.registrationId === replacement.binding.registrationId);
|
|
980
|
-
const
|
|
933
|
+
const byIdentity = state.routes.find((route) => route.binding.provider === "claude" &&
|
|
981
934
|
route.binding.hostId === replacement.binding.hostId &&
|
|
982
935
|
route.binding.routeHandle === replacement.binding.routeHandle);
|
|
983
|
-
if (
|
|
984
|
-
(
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
936
|
+
if (byRegistration !== undefined && byRegistration !== byIdentity) {
|
|
937
|
+
throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The Claude registration ID and native session do not identify one route.");
|
|
938
|
+
}
|
|
939
|
+
if (byIdentity !== undefined &&
|
|
940
|
+
byIdentity.binding.registrationId !== replacement.binding.registrationId) {
|
|
941
|
+
throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The native Claude session is already registered under another identity.");
|
|
942
|
+
}
|
|
943
|
+
const settlements = [];
|
|
944
|
+
if (byAlias !== undefined && byAlias !== byIdentity) {
|
|
945
|
+
if (byAlias.binding.provider !== "claude" || byAlias.registrationMode === "federated_peer") {
|
|
946
|
+
throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The alias belongs to a route that a Claude session cannot displace.");
|
|
947
|
+
}
|
|
948
|
+
settlements.push(...this.terminalizeRegistration(state, byAlias.binding.registrationId, now, "ENDPOINT_RETIRED"));
|
|
949
|
+
this.removeRegistrationMetadata(state, byAlias);
|
|
950
|
+
this.appendRuntimeActivity(state, now, {
|
|
951
|
+
kind: "registration", action: "claude_route_retired",
|
|
952
|
+
outcome: "accepted", aliases: [byAlias.alias], operatorAction: true,
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
if (byIdentity === undefined) {
|
|
995
956
|
if (state.routes.length >= this.config.limits.maxRoutes) {
|
|
996
957
|
throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
|
|
997
958
|
}
|
|
998
959
|
state.routes.push(routeRecord(replacement, now));
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
current.alias = replacement.alias;
|
|
1005
|
-
current.enabled = true;
|
|
1006
|
-
current.updatedAt = now.toISOString();
|
|
1007
|
-
return { settlements: [] };
|
|
1008
|
-
}
|
|
1009
|
-
const settlements = this.terminalizeRegistration(state, current.binding.registrationId, now);
|
|
1010
|
-
this.removeRegistrationMetadata(state, current);
|
|
1011
|
-
state.routes.push(routeRecord(replacement, now));
|
|
1012
|
-
return { settlements };
|
|
1013
|
-
});
|
|
1014
|
-
}
|
|
1015
|
-
async hasConsentEdge(input) {
|
|
1016
|
-
return this.read((state) => {
|
|
1017
|
-
const pair = this.resolvePair(state, input.aliases, false, input.expectedRegistrationIds);
|
|
1018
|
-
return (pair !== undefined &&
|
|
1019
|
-
state.consentEdges.some((edge) => sameConsent(edge.endpoints, pair.endpoints)));
|
|
1020
|
-
});
|
|
1021
|
-
}
|
|
1022
|
-
async addConsentEdge(input) {
|
|
1023
|
-
await this.mutate((state, now) => {
|
|
1024
|
-
const pair = this.resolvePair(state, input.aliases, true, input.expectedRegistrationIds);
|
|
1025
|
-
if (state.consentEdges.some((edge) => sameConsent(edge.endpoints, pair.endpoints))) {
|
|
1026
|
-
return;
|
|
960
|
+
this.appendRuntimeActivity(state, now, {
|
|
961
|
+
kind: "registration", action: "claude_route_installed",
|
|
962
|
+
outcome: "accepted", aliases: [replacement.alias], operatorAction: true,
|
|
963
|
+
});
|
|
964
|
+
return { installed: true, settlements };
|
|
1027
965
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
966
|
+
const priorAlias = byIdentity.alias;
|
|
967
|
+
if (priorAlias !== replacement.alias) {
|
|
968
|
+
this.renameRegistrationCoordinates(state, priorAlias, replacement.alias, byIdentity.binding.registrationId);
|
|
969
|
+
byIdentity.alias = replacement.alias;
|
|
970
|
+
this.appendRuntimeActivity(state, now, {
|
|
971
|
+
kind: "registration", action: "claude_route_installed",
|
|
972
|
+
outcome: "accepted", aliases: [priorAlias, replacement.alias], operatorAction: true,
|
|
973
|
+
});
|
|
1030
974
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
});
|
|
975
|
+
byIdentity.enabled = true;
|
|
976
|
+
byIdentity.updatedAt = now.toISOString();
|
|
977
|
+
return { installed: priorAlias !== replacement.alias, settlements };
|
|
1035
978
|
});
|
|
1036
979
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
980
|
+
/**
|
|
981
|
+
* The refusal half of admission, without the write. A send to a Claude
|
|
982
|
+
* session that has no route yet would otherwise install one — displacing
|
|
983
|
+
* another session and settling its queued work — on its way to being
|
|
984
|
+
* refused for its own reasons: too large, past the deadline window, rate
|
|
985
|
+
* limited, or into a full queue. Callers that are about to materialize a
|
|
986
|
+
* route run this first and materialize only if it returns.
|
|
987
|
+
*
|
|
988
|
+
* The refusal is still journaled, because a rejection is a fact about a real
|
|
989
|
+
* attempt; nothing else is written, and the authoritative checks still run
|
|
990
|
+
* inside `enqueueMessage` itself. `dedupeKey` is optional: a first send's key
|
|
991
|
+
* is broker-generated and cannot collide, and omitting it keeps this from
|
|
992
|
+
* inventing one to test against.
|
|
993
|
+
*/
|
|
994
|
+
async assertEnqueueAdmissible(input) {
|
|
995
|
+
const bytes = Buffer.byteLength(typeof input.body === "string" ? input.body : "", "utf8");
|
|
996
|
+
const refusal = await this.read((state) => {
|
|
997
|
+
const now = this.now();
|
|
998
|
+
if (bytes > this.config.limits.maxMessageBytes)
|
|
999
|
+
return "MESSAGE_TOO_LARGE";
|
|
1000
|
+
const deadlineAt = input.deadlineAt ??
|
|
1001
|
+
new Date(now.getTime() + this.config.limits.messageDeadlineMs).toISOString();
|
|
1002
|
+
if (!isIsoTimestamp(deadlineAt) || Date.parse(deadlineAt) <= now.getTime() ||
|
|
1003
|
+
Date.parse(deadlineAt) > now.getTime() + this.config.limits.messageDeadlineMs) {
|
|
1004
|
+
return "INVALID_DEADLINE";
|
|
1005
|
+
}
|
|
1006
|
+
// Read-only rate probe: the real enqueue consumes the token, this only
|
|
1007
|
+
// asks whether one is available.
|
|
1008
|
+
const bucket = state.rateBuckets.find((row) => row.sourceAlias === input.sourceAlias);
|
|
1009
|
+
const windowOpen = bucket === undefined ||
|
|
1010
|
+
now.getTime() - Date.parse(bucket.windowStartedAt) >= this.config.limits.rateWindowMs;
|
|
1011
|
+
if (windowOpen
|
|
1012
|
+
? state.rateBuckets.filter((row) => row.sourceAlias !== input.sourceAlias).length >=
|
|
1013
|
+
this.config.limits.maxRoutes
|
|
1014
|
+
: bucket.count >= this.config.limits.rateLimitPerRoute) {
|
|
1015
|
+
return "GATEWAY_RATE_LIMITED";
|
|
1016
|
+
}
|
|
1017
|
+
const active = state.messages.filter((message) => message.state.phase !== "terminal");
|
|
1018
|
+
if (active.length >= this.config.limits.maxQueueMessages ||
|
|
1019
|
+
active.filter((message) => message.targetAlias === input.targetAlias).length >=
|
|
1020
|
+
this.config.limits.maxQueueMessagesPerRoute ||
|
|
1021
|
+
state.accounting.queuedBytes + bytes > this.config.limits.maxQueueBytes) {
|
|
1022
|
+
return "GATEWAY_QUEUE_FULL";
|
|
1023
|
+
}
|
|
1024
|
+
return undefined;
|
|
1055
1025
|
});
|
|
1026
|
+
if (refusal === undefined)
|
|
1027
|
+
return;
|
|
1028
|
+
await this.mutate((state, now) => {
|
|
1029
|
+
this.recordRejection(state, { sourceAlias: input.sourceAlias, targetAlias: input.targetAlias,
|
|
1030
|
+
direction: input.direction, sourceRegistrationId: input.sourceRegistrationId,
|
|
1031
|
+
targetRegistrationId: input.sourceRegistrationId }, bytes, now, refusal, input);
|
|
1032
|
+
});
|
|
1033
|
+
throw new BridgeError(refusal, refusal === "MESSAGE_TOO_LARGE" ? "The message exceeds the configured byte bound."
|
|
1034
|
+
: refusal === "INVALID_DEADLINE" ? "The message deadline must fall inside the configured delivery window."
|
|
1035
|
+
: refusal === "GATEWAY_RATE_LIMITED" ? "The source exceeded the bounded gateway rate window."
|
|
1036
|
+
: "The bounded gateway queue is full.", refusal === "GATEWAY_RATE_LIMITED" || refusal === "GATEWAY_QUEUE_FULL");
|
|
1056
1037
|
}
|
|
1057
1038
|
async enqueueMessage(input) {
|
|
1058
1039
|
return this.mutate((state, now) => {
|
|
@@ -1071,12 +1052,14 @@ export class GatewayStore {
|
|
|
1071
1052
|
target.binding.registrationId !== input.expectedTargetRegistrationId)) {
|
|
1072
1053
|
throw new BridgeError("ROUTE_UNREGISTERED", "The correlated source or target registration is no longer installed.");
|
|
1073
1054
|
}
|
|
1074
|
-
|
|
1055
|
+
if (source.binding.provider === target.binding.provider &&
|
|
1056
|
+
source.binding.hostId === target.binding.hostId) {
|
|
1057
|
+
throw new BridgeError("ROUTE_DIRECTION_MISMATCH", "Messages route only between different providers or between configured hosts.");
|
|
1058
|
+
}
|
|
1075
1059
|
return this.enqueueResolved(state, now, input, {
|
|
1076
1060
|
sourceAlias: source.alias, targetAlias: target.alias,
|
|
1077
1061
|
direction: directionId(source.binding.provider, target.binding.provider),
|
|
1078
1062
|
sourceRegistrationId: source.binding.registrationId, targetRegistrationId: target.binding.registrationId,
|
|
1079
|
-
consentEdge: edge.endpoints, pair: true,
|
|
1080
1063
|
exposeDeliveryToken: true,
|
|
1081
1064
|
});
|
|
1082
1065
|
});
|
|
@@ -1099,68 +1082,26 @@ export class GatewayStore {
|
|
|
1099
1082
|
input.source.alias.endsWith(`@${nativeSourceHost}`) === false) {
|
|
1100
1083
|
throw new BridgeError("INVALID_NATIVE_PEER", "The native Claude sender does not match the target host.");
|
|
1101
1084
|
}
|
|
1102
|
-
|
|
1085
|
+
// The sender's own route was installed by the service on this send;
|
|
1086
|
+
// the exact binding fences a same-alias replacement that raced it.
|
|
1087
|
+
const source = state.routes.find((route) => route.alias === input.source.alias &&
|
|
1088
|
+
route.enabled &&
|
|
1103
1089
|
route.binding.provider === "claude" &&
|
|
1104
1090
|
route.binding.routeHandle === input.source.binding.routeHandle &&
|
|
1105
1091
|
route.binding.registrationId === input.source.binding.registrationId);
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
: state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, canonicalConsentEndpoints(selected, target)));
|
|
1109
|
-
if ((this.config.inboundMode === "paired" || target.registrationMode === "federated_peer") && edge === undefined) {
|
|
1110
|
-
this.recordRejection(state, {
|
|
1111
|
-
sourceAlias: input.source.alias, targetAlias: target.alias,
|
|
1112
|
-
direction: directionId("claude", target.binding.provider),
|
|
1113
|
-
sourceRegistrationId: selected?.binding.registrationId ?? null, targetRegistrationId: target.binding.registrationId,
|
|
1114
|
-
consentEdge: null,
|
|
1115
|
-
}, Buffer.byteLength(input.body, "utf8"), now, "SENDER_NOT_PAIRED", input);
|
|
1116
|
-
throw new CommitAndThrow(new BridgeError("SENDER_NOT_PAIRED", "The native Claude sender lacks exact durable consent."));
|
|
1092
|
+
if (source === undefined) {
|
|
1093
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The native Claude sender's route is no longer current.");
|
|
1117
1094
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
sourceRegistrationId: selected?.binding.registrationId ?? null,
|
|
1122
|
-
targetRegistrationId: target.binding.registrationId, consentEdge: edge?.endpoints ?? null,
|
|
1123
|
-
...(edge === undefined ? {} : { pair: true }),
|
|
1124
|
-
exposeDeliveryToken: false,
|
|
1125
|
-
});
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
async enqueueNativeReply(input) {
|
|
1129
|
-
return this.mutate((state, now) => {
|
|
1130
|
-
if (!ALIAS_PATTERN.test(input.sourceAlias) ||
|
|
1131
|
-
!isPrivateToken(input.expectedSourceRegistrationId)) {
|
|
1132
|
-
throw new BridgeError("INVALID_ROUTE_BINDING", "The native reply source authority is malformed.");
|
|
1133
|
-
}
|
|
1134
|
-
const source = state.routes.find((route) => route.alias === input.sourceAlias && route.enabled);
|
|
1135
|
-
if (source === undefined ||
|
|
1136
|
-
source.binding.registrationId !== input.expectedSourceRegistrationId) {
|
|
1137
|
-
throw new BridgeError("ROUTE_UNREGISTERED", "The native reply source registration is no longer current.");
|
|
1138
|
-
}
|
|
1139
|
-
if (!isLogicalBinding(input.target.binding) ||
|
|
1140
|
-
input.target.binding.provider !== "claude" ||
|
|
1141
|
-
input.target.binding.hostId !== source.binding.hostId ||
|
|
1142
|
-
!input.target.alias.endsWith(`@${source.binding.hostId}`)) {
|
|
1143
|
-
throw new BridgeError("INVALID_NATIVE_PEER", "The native Claude reply target does not match the source host.");
|
|
1144
|
-
}
|
|
1145
|
-
const selectedTarget = state.routes.find((route) => route.alias === input.target.alias &&
|
|
1146
|
-
route.binding.provider === "claude" &&
|
|
1147
|
-
route.binding.routeHandle === input.target.binding.routeHandle &&
|
|
1148
|
-
route.binding.registrationId === input.target.binding.registrationId);
|
|
1149
|
-
let edge;
|
|
1150
|
-
if (input.pair === true) {
|
|
1151
|
-
if (selectedTarget === undefined) {
|
|
1152
|
-
throw new BridgeError("SENDER_NOT_PAIRED", "The reply no longer matches an exact selected Claude route.");
|
|
1153
|
-
}
|
|
1154
|
-
edge = this.requireConsent(state, source, selectedTarget);
|
|
1095
|
+
if (source.binding.provider === target.binding.provider &&
|
|
1096
|
+
source.binding.hostId === target.binding.hostId) {
|
|
1097
|
+
throw new BridgeError("ROUTE_DIRECTION_MISMATCH", "Messages route only between different providers or between configured hosts.");
|
|
1155
1098
|
}
|
|
1156
1099
|
return this.enqueueResolved(state, now, input, {
|
|
1157
|
-
sourceAlias: source.alias, targetAlias:
|
|
1158
|
-
direction: directionId(
|
|
1100
|
+
sourceAlias: source.alias, targetAlias: target.alias,
|
|
1101
|
+
direction: directionId("claude", target.binding.provider),
|
|
1159
1102
|
sourceRegistrationId: source.binding.registrationId,
|
|
1160
|
-
targetRegistrationId:
|
|
1161
|
-
|
|
1162
|
-
...(edge === undefined ? { transientTarget: true } : { pair: true }),
|
|
1163
|
-
exposeDeliveryToken: input.exposeDeliveryToken === true,
|
|
1103
|
+
targetRegistrationId: target.binding.registrationId,
|
|
1104
|
+
exposeDeliveryToken: false,
|
|
1164
1105
|
});
|
|
1165
1106
|
});
|
|
1166
1107
|
}
|
|
@@ -1219,7 +1160,7 @@ export class GatewayStore {
|
|
|
1219
1160
|
attemptId,
|
|
1220
1161
|
attemptCount,
|
|
1221
1162
|
reservedAt: now.toISOString(), sourceRegistrationId: message.sourceRegistrationId,
|
|
1222
|
-
targetRegistrationId: message.targetRegistrationId,
|
|
1163
|
+
targetRegistrationId: message.targetRegistrationId,
|
|
1223
1164
|
};
|
|
1224
1165
|
state.accounting.queuedBytes -= message.bytes;
|
|
1225
1166
|
this.touchMessage(state, message);
|
|
@@ -1236,7 +1177,7 @@ export class GatewayStore {
|
|
|
1236
1177
|
? {}
|
|
1237
1178
|
: { conversationIdSuffix: message.conversationIdSuffix }),
|
|
1238
1179
|
sourceRegistrationId: message.sourceRegistrationId, targetRegistrationId: message.targetRegistrationId,
|
|
1239
|
-
|
|
1180
|
+
bytes: message.bytes,
|
|
1240
1181
|
...(message.steer === true ? { steer: true } : {}),
|
|
1241
1182
|
},
|
|
1242
1183
|
};
|
|
@@ -1269,26 +1210,20 @@ export class GatewayStore {
|
|
|
1269
1210
|
}
|
|
1270
1211
|
if (input.sourceRegistrationId !== message.sourceRegistrationId ||
|
|
1271
1212
|
input.targetRegistrationId !== message.targetRegistrationId ||
|
|
1272
|
-
|
|
1213
|
+
target === undefined) {
|
|
1273
1214
|
return { status: "stale", reason: "registration_changed" };
|
|
1274
1215
|
}
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
return { status: "stale", reason: "registration_changed" };
|
|
1281
|
-
}
|
|
1282
|
-
}
|
|
1283
|
-
if (message.consentEdge !== null &&
|
|
1284
|
-
!state.consentEdges.some((edge) => sameConsent(edge.endpoints, message.consentEdge))) {
|
|
1285
|
-
return { status: "stale", reason: "consent_removed" };
|
|
1216
|
+
const source = state.routes.find((route) => route.alias === message.sourceAlias &&
|
|
1217
|
+
route.binding.registrationId === message.sourceRegistrationId &&
|
|
1218
|
+
route.enabled);
|
|
1219
|
+
if (source === undefined) {
|
|
1220
|
+
return { status: "stale", reason: "registration_changed" };
|
|
1286
1221
|
}
|
|
1287
1222
|
const authority = message.state;
|
|
1288
1223
|
message.state = {
|
|
1289
1224
|
phase: "armed", attemptId: authority.attemptId,
|
|
1290
1225
|
attemptCount: authority.attemptCount, targetRegistrationId: authority.targetRegistrationId,
|
|
1291
|
-
sourceRegistrationId: authority.sourceRegistrationId,
|
|
1226
|
+
sourceRegistrationId: authority.sourceRegistrationId,
|
|
1292
1227
|
armedAt: now.toISOString(), prepared: { ...input.prepared },
|
|
1293
1228
|
};
|
|
1294
1229
|
this.touchMessage(state, message);
|
|
@@ -1306,7 +1241,7 @@ export class GatewayStore {
|
|
|
1306
1241
|
message.state = {
|
|
1307
1242
|
phase: "accepted", attemptId: authority.attemptId,
|
|
1308
1243
|
attemptCount: authority.attemptCount, targetRegistrationId: authority.targetRegistrationId,
|
|
1309
|
-
sourceRegistrationId: authority.sourceRegistrationId,
|
|
1244
|
+
sourceRegistrationId: authority.sourceRegistrationId,
|
|
1310
1245
|
acceptedAt: now.toISOString(), prepared: authority.prepared,
|
|
1311
1246
|
lossOutcome: input.lossOutcome,
|
|
1312
1247
|
};
|
|
@@ -1349,8 +1284,7 @@ export class GatewayStore {
|
|
|
1349
1284
|
if ((message.state.phase === "armed" &&
|
|
1350
1285
|
(input.state === "expired" ||
|
|
1351
1286
|
(input.state === "unconfirmed" &&
|
|
1352
|
-
!(
|
|
1353
|
-
(input.safeErrorCode === "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" && message.state.prepared.kind === "peer_handoff"))))) ||
|
|
1287
|
+
!(input.safeErrorCode === "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" && message.state.prepared.kind === "peer_handoff")))) ||
|
|
1354
1288
|
(message.state.phase === "accepted" &&
|
|
1355
1289
|
(input.state === "expired" ||
|
|
1356
1290
|
(input.state === "unconfirmed" &&
|
|
@@ -1438,18 +1372,6 @@ export class GatewayStore {
|
|
|
1438
1372
|
mutable: route.registrationMode !== "federated_peer",
|
|
1439
1373
|
};
|
|
1440
1374
|
});
|
|
1441
|
-
const consentEdges = state.consentEdges.map((edge) => ({
|
|
1442
|
-
endpoints: [
|
|
1443
|
-
{
|
|
1444
|
-
alias: edge.endpoints[0].alias, provider: edge.endpoints[0].provider,
|
|
1445
|
-
},
|
|
1446
|
-
{
|
|
1447
|
-
alias: edge.endpoints[1].alias, provider: edge.endpoints[1].provider,
|
|
1448
|
-
},
|
|
1449
|
-
],
|
|
1450
|
-
host: edgeOwnerHost(edge.endpoints), counters: { ...edge.counters },
|
|
1451
|
-
mutable: edgeOwnerHost(edge.endpoints) === this.config.hostId,
|
|
1452
|
-
}));
|
|
1453
1375
|
const currentEvents = state.messages.map((message) => this.projectMessageEvent(message));
|
|
1454
1376
|
const messageEvents = state.activity
|
|
1455
1377
|
.filter((entry) => entry.type === "message_activity")
|
|
@@ -1479,10 +1401,9 @@ export class GatewayStore {
|
|
|
1479
1401
|
}
|
|
1480
1402
|
return projectGatewayPublicSnapshot({
|
|
1481
1403
|
schemaVersion: 2, generatedAt: now.toISOString(),
|
|
1482
|
-
|
|
1404
|
+
health: "offline",
|
|
1483
1405
|
connectors: [], availablePeers: [],
|
|
1484
1406
|
routes,
|
|
1485
|
-
consentEdges,
|
|
1486
1407
|
activityEvents,
|
|
1487
1408
|
deadlinePressure: {
|
|
1488
1409
|
configuredDeadlineMs: this.config.limits.messageDeadlineMs,
|
|
@@ -1497,7 +1418,7 @@ export class GatewayStore {
|
|
|
1497
1418
|
accounting: { ...state.accounting }, alerts: [],
|
|
1498
1419
|
truncation: {
|
|
1499
1420
|
connectors: 0, availablePeers: 0,
|
|
1500
|
-
routes: 0,
|
|
1421
|
+
routes: 0,
|
|
1501
1422
|
activityEvents: 0,
|
|
1502
1423
|
messages: Math.max(0, messageEvents.length + currentEvents.length - messages.length),
|
|
1503
1424
|
alerts: 0,
|
|
@@ -1570,51 +1491,6 @@ export class GatewayStore {
|
|
|
1570
1491
|
}
|
|
1571
1492
|
return route;
|
|
1572
1493
|
}
|
|
1573
|
-
resolvePair(state, aliases, required, expectedRegistrationIds) {
|
|
1574
|
-
if (aliases.length !== 2 ||
|
|
1575
|
-
aliases[0] === aliases[1] ||
|
|
1576
|
-
!aliases.every((alias) => ALIAS_PATTERN.test(alias))) {
|
|
1577
|
-
if (required) {
|
|
1578
|
-
throw new BridgeError("INVALID_CONSENT_EDGE", "Consent requires two distinct exact route aliases.");
|
|
1579
|
-
}
|
|
1580
|
-
return undefined;
|
|
1581
|
-
}
|
|
1582
|
-
const left = state.routes.find((route) => route.alias === aliases[0]);
|
|
1583
|
-
const right = state.routes.find((route) => route.alias === aliases[1]);
|
|
1584
|
-
if (expectedRegistrationIds === undefined ||
|
|
1585
|
-
!expectedRegistrationIds.every(isPrivateToken)) {
|
|
1586
|
-
if (required) {
|
|
1587
|
-
throw new BridgeError("INVALID_CONSENT_EDGE", "Consent requires both exact registration identities.");
|
|
1588
|
-
}
|
|
1589
|
-
return undefined;
|
|
1590
|
-
}
|
|
1591
|
-
if (left?.binding.registrationId !== expectedRegistrationIds[0] ||
|
|
1592
|
-
right?.binding.registrationId !== expectedRegistrationIds[1]) {
|
|
1593
|
-
throw new BridgeError("ROUTE_UNREGISTERED", "A consent endpoint registration is no longer current.");
|
|
1594
|
-
}
|
|
1595
|
-
if (left === undefined ||
|
|
1596
|
-
right === undefined ||
|
|
1597
|
-
(left.binding.provider === right.binding.provider &&
|
|
1598
|
-
left.binding.hostId === right.binding.hostId)) {
|
|
1599
|
-
if (required) {
|
|
1600
|
-
throw new BridgeError("INVALID_CONSENT_EDGE", "Consent requires distinct providers or distinct configured hosts.");
|
|
1601
|
-
}
|
|
1602
|
-
return undefined;
|
|
1603
|
-
}
|
|
1604
|
-
return {
|
|
1605
|
-
left,
|
|
1606
|
-
right,
|
|
1607
|
-
endpoints: canonicalConsentEndpoints(left, right),
|
|
1608
|
-
};
|
|
1609
|
-
}
|
|
1610
|
-
requireConsent(state, source, target) {
|
|
1611
|
-
const endpoints = canonicalConsentEndpoints(source, target);
|
|
1612
|
-
const edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, endpoints));
|
|
1613
|
-
if (edge === undefined) {
|
|
1614
|
-
throw new BridgeError("SENDER_NOT_PAIRED", "The exact logical routes do not share consent.");
|
|
1615
|
-
}
|
|
1616
|
-
return edge;
|
|
1617
|
-
}
|
|
1618
1494
|
enqueueResolved(state, now, input, authority) {
|
|
1619
1495
|
if (typeof input.body !== "string" ||
|
|
1620
1496
|
input.body.length === 0 ||
|
|
@@ -1627,8 +1503,7 @@ export class GatewayStore {
|
|
|
1627
1503
|
}
|
|
1628
1504
|
const bytes = Buffer.byteLength(input.body, "utf8");
|
|
1629
1505
|
if (bytes > this.config.limits.maxMessageBytes) {
|
|
1630
|
-
this.
|
|
1631
|
-
throw new CommitAndThrow(new BridgeError("MESSAGE_TOO_LARGE", "The message exceeds the configured byte bound."));
|
|
1506
|
+
this.rejectEnqueue(state, authority, bytes, now, input, "MESSAGE_TOO_LARGE", "The message exceeds the configured byte bound.");
|
|
1632
1507
|
}
|
|
1633
1508
|
if (Buffer.byteLength(input.dedupeKey, "utf8") > 512) {
|
|
1634
1509
|
throw new BridgeError("INVALID_DEDUPE_KEY", "A bounded, non-empty deduplication key is required.");
|
|
@@ -1638,8 +1513,7 @@ export class GatewayStore {
|
|
|
1638
1513
|
if (!isIsoTimestamp(deadlineAt) ||
|
|
1639
1514
|
Date.parse(deadlineAt) <= now.getTime() ||
|
|
1640
1515
|
Date.parse(deadlineAt) > now.getTime() + this.config.limits.messageDeadlineMs) {
|
|
1641
|
-
this.
|
|
1642
|
-
throw new CommitAndThrow(new BridgeError("INVALID_DEADLINE", "The message deadline must fall inside the configured delivery window."));
|
|
1516
|
+
this.rejectEnqueue(state, authority, bytes, now, input, "INVALID_DEADLINE", "The message deadline must fall inside the configured delivery window.");
|
|
1643
1517
|
}
|
|
1644
1518
|
const fingerprint = createHash("sha256")
|
|
1645
1519
|
.update([
|
|
@@ -1659,8 +1533,7 @@ export class GatewayStore {
|
|
|
1659
1533
|
};
|
|
1660
1534
|
}
|
|
1661
1535
|
if (!this.consumeRate(state, authority.sourceAlias, now)) {
|
|
1662
|
-
this.
|
|
1663
|
-
throw new CommitAndThrow(new BridgeError("GATEWAY_RATE_LIMITED", "The source exceeded the bounded gateway rate window.", true));
|
|
1536
|
+
this.rejectEnqueue(state, authority, bytes, now, input, "GATEWAY_RATE_LIMITED", "The source exceeded the bounded gateway rate window.", true);
|
|
1664
1537
|
}
|
|
1665
1538
|
const active = state.messages.filter((message) => message.state.phase !== "terminal");
|
|
1666
1539
|
const queuedSteers = input.steer === true
|
|
@@ -1680,8 +1553,7 @@ export class GatewayStore {
|
|
|
1680
1553
|
this.config.limits.maxQueueMessagesPerRoute ||
|
|
1681
1554
|
state.accounting.queuedBytes - (superseded?.bytes ?? 0) + bytes >
|
|
1682
1555
|
this.config.limits.maxQueueBytes) {
|
|
1683
|
-
this.
|
|
1684
|
-
throw new CommitAndThrow(new BridgeError("GATEWAY_QUEUE_FULL", "The bounded gateway queue is full.", true));
|
|
1556
|
+
this.rejectEnqueue(state, authority, bytes, now, input, "GATEWAY_QUEUE_FULL", "The bounded gateway queue is full.", true);
|
|
1685
1557
|
}
|
|
1686
1558
|
const messageId = `msg_${this.randomId()}`;
|
|
1687
1559
|
if (!MESSAGE_ID_PATTERN.test(messageId)) {
|
|
@@ -1708,13 +1580,8 @@ export class GatewayStore {
|
|
|
1708
1580
|
deadlineAt,
|
|
1709
1581
|
bytes,
|
|
1710
1582
|
body: input.body,
|
|
1711
|
-
...(authority.pair === true ? { pair: true } : {}),
|
|
1712
|
-
...(authority.transientTarget === true
|
|
1713
|
-
? { transientTarget: true }
|
|
1714
|
-
: {}),
|
|
1715
1583
|
...(input.steer === true ? { steer: true } : {}),
|
|
1716
1584
|
sourceRegistrationId: authority.sourceRegistrationId, targetRegistrationId: authority.targetRegistrationId,
|
|
1717
|
-
consentEdge: authority.consentEdge,
|
|
1718
1585
|
state: { phase: "queued", attemptCount: 0 },
|
|
1719
1586
|
};
|
|
1720
1587
|
state.messages.push(message);
|
|
@@ -1726,12 +1593,6 @@ export class GatewayStore {
|
|
|
1726
1593
|
source.counters.accepted += 1;
|
|
1727
1594
|
source.counters.bytesAccepted += bytes;
|
|
1728
1595
|
}
|
|
1729
|
-
const edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, authority.consentEdge));
|
|
1730
|
-
if (edge !== undefined) {
|
|
1731
|
-
edge.counters.accepted += 1;
|
|
1732
|
-
edge.counters.bytesAccepted += bytes;
|
|
1733
|
-
edge.updatedAt = now.toISOString();
|
|
1734
|
-
}
|
|
1735
1596
|
state.dedupe.push({
|
|
1736
1597
|
fingerprint,
|
|
1737
1598
|
messageIdSuffix,
|
|
@@ -1740,7 +1601,6 @@ export class GatewayStore {
|
|
|
1740
1601
|
: { conversationIdSuffix: input.conversationIdSuffix }),
|
|
1741
1602
|
sourceAlias: authority.sourceAlias, targetAlias: authority.targetAlias,
|
|
1742
1603
|
direction: authority.direction,
|
|
1743
|
-
...(authority.pair === true ? { pair: true } : {}),
|
|
1744
1604
|
firstSeenAt: now.toISOString(),
|
|
1745
1605
|
expiresAt: new Date(now.getTime() + this.config.limits.dedupeTtlMs).toISOString(),
|
|
1746
1606
|
});
|
|
@@ -1752,22 +1612,11 @@ export class GatewayStore {
|
|
|
1752
1612
|
messageId,
|
|
1753
1613
|
messageIdSuffix,
|
|
1754
1614
|
...(deliveryToken === undefined ? {} : { deliveryToken }),
|
|
1755
|
-
...(authority.pair === true ? { pair: true } : {}),
|
|
1756
1615
|
...(supersededSettlement === undefined
|
|
1757
1616
|
? {}
|
|
1758
1617
|
: { supersededSettlement }),
|
|
1759
1618
|
};
|
|
1760
1619
|
}
|
|
1761
|
-
terminalizeConsentEdge(state, edge, now) {
|
|
1762
|
-
return state.messages.filter((message) => message.state.phase !== "terminal" &&
|
|
1763
|
-
sameConsent(message.consentEdge, edge.endpoints)).map((message) => {
|
|
1764
|
-
const outcome = message.state.phase === "armed" ? "ambiguous" :
|
|
1765
|
-
message.state.phase === "accepted" ? message.state.lossOutcome : "cancelled";
|
|
1766
|
-
return this.finishMessage(state, message, outcome, now, outcome === "ambiguous"
|
|
1767
|
-
? "DISPATCH_OUTCOME_AMBIGUOUS" : outcome === "unconfirmed"
|
|
1768
|
-
? "DELIVERY_UNCONFIRMED" : "SENDER_NOT_PAIRED");
|
|
1769
|
-
});
|
|
1770
|
-
}
|
|
1771
1620
|
allocateDeliveryToken(state) {
|
|
1772
1621
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
1773
1622
|
const token = `dlv_${createHash("sha256")
|
|
@@ -1802,16 +1651,15 @@ export class GatewayStore {
|
|
|
1802
1651
|
bucket.count += 1;
|
|
1803
1652
|
return true;
|
|
1804
1653
|
}
|
|
1654
|
+
rejectEnqueue(state, authority, bytes, now, input, code, message, recoverable = false) {
|
|
1655
|
+
this.recordRejection(state, authority, bytes, now, code, input);
|
|
1656
|
+
throw new CommitAndThrow(new BridgeError(code, message, recoverable));
|
|
1657
|
+
}
|
|
1805
1658
|
recordRejection(state, authority, bytes, now, safeErrorCode, input) {
|
|
1806
1659
|
state.accounting.rejected += 1;
|
|
1807
1660
|
const source = state.routes.find((route) => route.binding.registrationId === authority.sourceRegistrationId);
|
|
1808
1661
|
if (source !== undefined)
|
|
1809
1662
|
source.counters.rejected += 1;
|
|
1810
|
-
const edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, authority.consentEdge));
|
|
1811
|
-
if (edge !== undefined) {
|
|
1812
|
-
edge.counters.rejected += 1;
|
|
1813
|
-
edge.updatedAt = now.toISOString();
|
|
1814
|
-
}
|
|
1815
1663
|
const suffix = this.randomId().replaceAll("-", "").slice(-8).toLowerCase();
|
|
1816
1664
|
if (!MESSAGE_SUFFIX_PATTERN.test(suffix))
|
|
1817
1665
|
return;
|
|
@@ -1858,11 +1706,6 @@ export class GatewayStore {
|
|
|
1858
1706
|
const target = state.routes.find((route) => route.binding.registrationId === message.targetRegistrationId);
|
|
1859
1707
|
if (target !== undefined)
|
|
1860
1708
|
target.counters[outcome] += 1;
|
|
1861
|
-
const edge = state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, message.consentEdge));
|
|
1862
|
-
if (edge !== undefined) {
|
|
1863
|
-
edge.counters[outcome] += 1;
|
|
1864
|
-
edge.updatedAt = now.toISOString();
|
|
1865
|
-
}
|
|
1866
1709
|
return {
|
|
1867
1710
|
messageId: message.messageId, state: outcome,
|
|
1868
1711
|
...(safeErrorCode === undefined ? {} : { safeErrorCode }),
|
|
@@ -1887,7 +1730,7 @@ export class GatewayStore {
|
|
|
1887
1730
|
}
|
|
1888
1731
|
return structuredClone(recorded);
|
|
1889
1732
|
}
|
|
1890
|
-
terminalizeRegistration(state, registrationId, now) {
|
|
1733
|
+
terminalizeRegistration(state, registrationId, now, cancelledCode = "ROUTE_UNREGISTERED") {
|
|
1891
1734
|
const settlements = [];
|
|
1892
1735
|
for (const message of state.messages) {
|
|
1893
1736
|
if (message.state.phase === "terminal" ||
|
|
@@ -1901,7 +1744,7 @@ export class GatewayStore {
|
|
|
1901
1744
|
? message.state.lossOutcome
|
|
1902
1745
|
: "cancelled";
|
|
1903
1746
|
settlements.push(this.finishMessage(state, message, outcome, now, outcome === "cancelled"
|
|
1904
|
-
?
|
|
1747
|
+
? cancelledCode
|
|
1905
1748
|
: outcome === "ambiguous"
|
|
1906
1749
|
? "DISPATCH_OUTCOME_AMBIGUOUS"
|
|
1907
1750
|
: "DELIVERY_UNCONFIRMED"));
|
|
@@ -1909,8 +1752,16 @@ export class GatewayStore {
|
|
|
1909
1752
|
return settlements;
|
|
1910
1753
|
}
|
|
1911
1754
|
removeRegistrationMetadata(state, route) {
|
|
1755
|
+
if (this.ephemeralRegistrations.has(route.binding.registrationId)) {
|
|
1756
|
+
// An ephemeral registration takes every row it owns with it — in this
|
|
1757
|
+
// same mutate, and before its id leaves the set — so the closing
|
|
1758
|
+
// persist finds nothing of it to write: not the message it sent, not
|
|
1759
|
+
// the reply it received, not a journal row that names it.
|
|
1760
|
+
Object.assign(state, this.ephemeralRowsRemoved(state, new Set([route.binding.registrationId]), new Set([route.alias])));
|
|
1761
|
+
this.ephemeralRegistrations.delete(route.binding.registrationId);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1912
1764
|
state.routes = state.routes.filter((candidate) => candidate !== route);
|
|
1913
|
-
state.consentEdges = state.consentEdges.filter((edge) => !edge.endpoints.some((endpoint) => endpoint.registrationId === route.binding.registrationId));
|
|
1914
1765
|
state.dedupe = state.dedupe.filter((entry) => entry.sourceAlias !== route.alias && entry.targetAlias !== route.alias);
|
|
1915
1766
|
state.rateBuckets = state.rateBuckets.filter((entry) => entry.sourceAlias !== route.alias);
|
|
1916
1767
|
}
|
|
@@ -1921,13 +1772,6 @@ export class GatewayStore {
|
|
|
1921
1772
|
state.routes.some((route) => route.alias === newAlias)) {
|
|
1922
1773
|
throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The replacement alias is invalid or already registered.");
|
|
1923
1774
|
}
|
|
1924
|
-
for (const edge of state.consentEdges) {
|
|
1925
|
-
const updated = edge.endpoints.map((endpoint) => endpoint.registrationId === registrationId
|
|
1926
|
-
? { ...endpoint, alias: newAlias }
|
|
1927
|
-
: endpoint);
|
|
1928
|
-
updated.sort(compareConsentEndpoints);
|
|
1929
|
-
edge.endpoints = updated;
|
|
1930
|
-
}
|
|
1931
1775
|
for (const message of state.messages) {
|
|
1932
1776
|
if (message.sourceRegistrationId === registrationId) {
|
|
1933
1777
|
message.sourceAlias = newAlias;
|
|
@@ -1935,24 +1779,6 @@ export class GatewayStore {
|
|
|
1935
1779
|
if (message.targetRegistrationId === registrationId) {
|
|
1936
1780
|
message.targetAlias = newAlias;
|
|
1937
1781
|
}
|
|
1938
|
-
if (message.consentEdge !== null) {
|
|
1939
|
-
const updated = message.consentEdge.map((endpoint) => endpoint.registrationId === registrationId
|
|
1940
|
-
? { ...endpoint, alias: newAlias }
|
|
1941
|
-
: endpoint);
|
|
1942
|
-
updated.sort(compareConsentEndpoints);
|
|
1943
|
-
message.consentEdge = updated;
|
|
1944
|
-
}
|
|
1945
|
-
if (message.state.phase === "reserved" ||
|
|
1946
|
-
message.state.phase === "armed" ||
|
|
1947
|
-
message.state.phase === "accepted") {
|
|
1948
|
-
if (message.state.consentEdge !== null) {
|
|
1949
|
-
const updated = message.state.consentEdge.map((endpoint) => endpoint.registrationId === registrationId
|
|
1950
|
-
? { ...endpoint, alias: newAlias }
|
|
1951
|
-
: endpoint);
|
|
1952
|
-
updated.sort(compareConsentEndpoints);
|
|
1953
|
-
message.state.consentEdge = updated;
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
1782
|
}
|
|
1957
1783
|
for (const entry of state.dedupe) {
|
|
1958
1784
|
if (entry.sourceAlias === oldAlias)
|
|
@@ -1996,10 +1822,6 @@ export class GatewayStore {
|
|
|
1996
1822
|
: "CONTROLLER_RESTARTED");
|
|
1997
1823
|
continue;
|
|
1998
1824
|
}
|
|
1999
|
-
if (message.transientTarget === true) {
|
|
2000
|
-
this.finishMessage(state, message, "abandoned", now, "CONTROLLER_RESTARTED");
|
|
2001
|
-
continue;
|
|
2002
|
-
}
|
|
2003
1825
|
if (message.state.phase === "reserved") {
|
|
2004
1826
|
const attemptCount = message.state.attemptCount;
|
|
2005
1827
|
message.state = { phase: "queued", attemptCount };
|
|
@@ -2054,7 +1876,6 @@ export class GatewayStore {
|
|
|
2054
1876
|
if (state.routes.some((route) => !this.config.allowedHosts.includes(route.binding.hostId) ||
|
|
2055
1877
|
!routeModeMatchesHost(route, this.config.hostId)) ||
|
|
2056
1878
|
state.routes.length > this.config.limits.maxRoutes ||
|
|
2057
|
-
state.consentEdges.length > this.config.limits.maxConsentEdges ||
|
|
2058
1879
|
active.length > this.config.limits.maxQueueMessages ||
|
|
2059
1880
|
active.filter((message) => message.state.phase === "reserved" ||
|
|
2060
1881
|
message.state.phase === "armed" ||
|
|
@@ -2075,6 +1896,16 @@ export class GatewayStore {
|
|
|
2075
1896
|
return this.mutex.run("gateway", async () => {
|
|
2076
1897
|
const state = this.requireState();
|
|
2077
1898
|
const before = structuredClone(state);
|
|
1899
|
+
// The ephemeral set is state too: a removal drops an id from it inside
|
|
1900
|
+
// the operation, and a rollback that restored the rows but not the id
|
|
1901
|
+
// would turn a memory-only registration into a durable one.
|
|
1902
|
+
const ephemeralBefore = new Set(this.ephemeralRegistrations);
|
|
1903
|
+
const rollback = () => {
|
|
1904
|
+
this.state = before;
|
|
1905
|
+
this.ephemeralRegistrations.clear();
|
|
1906
|
+
for (const id of ephemeralBefore)
|
|
1907
|
+
this.ephemeralRegistrations.add(id);
|
|
1908
|
+
};
|
|
2078
1909
|
const now = this.now();
|
|
2079
1910
|
this.prune(state, now);
|
|
2080
1911
|
let value;
|
|
@@ -2091,14 +1922,13 @@ export class GatewayStore {
|
|
|
2091
1922
|
await this.persist();
|
|
2092
1923
|
}
|
|
2093
1924
|
catch (persistError) {
|
|
2094
|
-
if (!(persistError instanceof PostRenamePersistenceError))
|
|
2095
|
-
|
|
2096
|
-
}
|
|
1925
|
+
if (!(persistError instanceof PostRenamePersistenceError))
|
|
1926
|
+
rollback();
|
|
2097
1927
|
throw persistError;
|
|
2098
1928
|
}
|
|
2099
1929
|
throw error.error;
|
|
2100
1930
|
}
|
|
2101
|
-
|
|
1931
|
+
rollback();
|
|
2102
1932
|
throw error;
|
|
2103
1933
|
}
|
|
2104
1934
|
state.updatedAt = now.toISOString();
|
|
@@ -2110,7 +1940,7 @@ export class GatewayStore {
|
|
|
2110
1940
|
}
|
|
2111
1941
|
catch (error) {
|
|
2112
1942
|
if (!(error instanceof PostRenamePersistenceError))
|
|
2113
|
-
|
|
1943
|
+
rollback();
|
|
2114
1944
|
throw error;
|
|
2115
1945
|
}
|
|
2116
1946
|
return value;
|
|
@@ -2189,6 +2019,58 @@ export class GatewayStore {
|
|
|
2189
2019
|
}
|
|
2190
2020
|
return root;
|
|
2191
2021
|
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Boot-time litter sweep (emb-106): the static `gateway-dashboard*.html`
|
|
2024
|
+
* files 2.x published, any `.tmp` file its temp-file+rename publish could
|
|
2025
|
+
* have left behind mid-crash, and controller locks this store recovered as
|
|
2026
|
+
* stale more than seven days ago, are unlinked best-effort. Only a
|
|
2027
|
+
* regular file owned by this uid is removed (lstat, no symlink following);
|
|
2028
|
+
* nothing else in the state directory is touched, and a removal failure
|
|
2029
|
+
* never blocks startup. Runs only after the controller lock is held and
|
|
2030
|
+
* the state this boot will run on has passed both its schema check and
|
|
2031
|
+
* assertConfiguredBounds, so a refused boot leaves the directory exactly
|
|
2032
|
+
* as found.
|
|
2033
|
+
*/
|
|
2034
|
+
async removeStateDirectoryLitter(root) {
|
|
2035
|
+
let entries;
|
|
2036
|
+
try {
|
|
2037
|
+
entries = await readdir(root);
|
|
2038
|
+
}
|
|
2039
|
+
catch {
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
2043
|
+
const now = this.now().getTime();
|
|
2044
|
+
for (const name of entries) {
|
|
2045
|
+
const dashboard = isStaleDashboardArtifact(name);
|
|
2046
|
+
const recoveredAt = dashboard ? undefined : staleLockRecoveredAt(name);
|
|
2047
|
+
if (!dashboard && recoveredAt === undefined)
|
|
2048
|
+
continue;
|
|
2049
|
+
const filePath = path.join(root, name);
|
|
2050
|
+
let info;
|
|
2051
|
+
try {
|
|
2052
|
+
info = await lstat(filePath);
|
|
2053
|
+
}
|
|
2054
|
+
catch {
|
|
2055
|
+
continue;
|
|
2056
|
+
}
|
|
2057
|
+
if (info.isSymbolicLink() || !info.isFile() || (uid !== undefined && info.uid !== uid))
|
|
2058
|
+
continue;
|
|
2059
|
+
// A recovered lock is evidence: keep it a week from the recovery that
|
|
2060
|
+
// created it, which is what its name records.
|
|
2061
|
+
if (recoveredAt !== undefined && now - recoveredAt <= STALE_LOCK_RETENTION_MS)
|
|
2062
|
+
continue;
|
|
2063
|
+
try {
|
|
2064
|
+
await unlink(filePath);
|
|
2065
|
+
process.stderr.write(dashboard
|
|
2066
|
+
? `[embassy] removed stale ${name} left by an earlier Embassy release from the gateway state directory\n`
|
|
2067
|
+
: `[embassy] removed ${name}, a recovered gateway lock older than seven days\n`);
|
|
2068
|
+
}
|
|
2069
|
+
catch {
|
|
2070
|
+
// Best-effort cleanup; leave the file in place if it cannot be removed.
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2192
2074
|
assertOwnedPrivate(uid, mode, kind) {
|
|
2193
2075
|
if (typeof process.getuid === "function" && uid !== process.getuid()) {
|
|
2194
2076
|
throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", `The gateway ${kind} is not owned by the current process user.`);
|
|
@@ -2238,6 +2120,23 @@ export class GatewayStore {
|
|
|
2238
2120
|
await handle.close();
|
|
2239
2121
|
}
|
|
2240
2122
|
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Move a lock nobody owns aside instead of deleting it: a crash stays
|
|
2125
|
+
* diagnosable for a week (the boot sweep ages these out), and the caller
|
|
2126
|
+
* is told, because a silent recovery of another process's lock would be
|
|
2127
|
+
* the one event an operator most needs to see.
|
|
2128
|
+
*/
|
|
2129
|
+
async recoverStaleLock(lockPath, description) {
|
|
2130
|
+
const name = `${CONTROLLER_LOCK}.stale-${String(this.now().getTime())}-${randomUUID()}`;
|
|
2131
|
+
const moved = await rename(lockPath, path.join(this.rootDir, name)).then(() => true, (error) => {
|
|
2132
|
+
// Another start recovered it first; retry the create either way.
|
|
2133
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
2134
|
+
return false;
|
|
2135
|
+
throw error;
|
|
2136
|
+
});
|
|
2137
|
+
if (moved)
|
|
2138
|
+
process.stderr.write(`[embassy] recovered a stale gateway lock (${description}) → ${name}\n`);
|
|
2139
|
+
}
|
|
2241
2140
|
async acquireLock() {
|
|
2242
2141
|
const lockPath = path.join(this.rootDir, CONTROLLER_LOCK);
|
|
2243
2142
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
@@ -2250,7 +2149,7 @@ export class GatewayStore {
|
|
|
2250
2149
|
try {
|
|
2251
2150
|
await handle.writeFile(`${JSON.stringify({
|
|
2252
2151
|
schemaVersion: 1, pid: process.pid,
|
|
2253
|
-
hostname:
|
|
2152
|
+
hostname: this.hostname(),
|
|
2254
2153
|
token,
|
|
2255
2154
|
})}\n`, "utf8");
|
|
2256
2155
|
await handle.sync();
|
|
@@ -2271,35 +2170,54 @@ export class GatewayStore {
|
|
|
2271
2170
|
throw error;
|
|
2272
2171
|
}
|
|
2273
2172
|
}
|
|
2274
|
-
let
|
|
2173
|
+
let body;
|
|
2275
2174
|
try {
|
|
2276
|
-
|
|
2175
|
+
body = await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES);
|
|
2277
2176
|
}
|
|
2278
2177
|
catch {
|
|
2279
|
-
throw
|
|
2178
|
+
throw lockUnverified();
|
|
2280
2179
|
}
|
|
2281
|
-
|
|
2282
|
-
|
|
2180
|
+
// A crash between the O_EXCL create and the write leaves a zero-byte
|
|
2181
|
+
// lock. It records no owner at all, so it can only be the corpse of a
|
|
2182
|
+
// start that never began: recover it rather than wedging on it.
|
|
2183
|
+
if (body.trim().length === 0) {
|
|
2184
|
+
await this.recoverStaleLock(lockPath, "empty, left by an interrupted start");
|
|
2185
|
+
continue;
|
|
2283
2186
|
}
|
|
2284
|
-
let
|
|
2187
|
+
let owner;
|
|
2285
2188
|
try {
|
|
2286
|
-
|
|
2287
|
-
}
|
|
2288
|
-
catch (error) {
|
|
2289
|
-
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
|
2290
|
-
alive = false;
|
|
2291
|
-
}
|
|
2292
|
-
}
|
|
2293
|
-
if (alive) {
|
|
2294
|
-
throw new BridgeError("GATEWAY_STATE_IN_USE", "Another live gateway controller owns this state directory.", true);
|
|
2189
|
+
owner = JSON.parse(body);
|
|
2295
2190
|
}
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2191
|
+
catch {
|
|
2192
|
+
throw lockUnverified();
|
|
2193
|
+
}
|
|
2194
|
+
// Parsed, but naming no process: there is nothing to probe and nothing
|
|
2195
|
+
// to claim about it, so this is unverified like any other unreadable
|
|
2196
|
+
// record — never "another broker may own this".
|
|
2197
|
+
if (!isPositiveInteger(owner.pid))
|
|
2198
|
+
throw lockUnverified();
|
|
2199
|
+
// Recovery is decided by process liveness alone; the recorded hostname
|
|
2200
|
+
// is informational. A machine that renames itself — a network-triggered
|
|
2201
|
+
// rename, a restore onto new hardware — must not wedge its own state
|
|
2202
|
+
// directory forever, so a dead pid is stale whatever name wrote it.
|
|
2203
|
+
const recorded = boundedHostname(owner.hostname);
|
|
2204
|
+
const pid = String(owner.pid);
|
|
2205
|
+
// A name this cannot represent is not reported as a name: the client
|
|
2206
|
+
// hint drops the whole clause rather than printing a placeholder.
|
|
2207
|
+
const detail = recorded === undefined ? { pid } : { host: recorded, pid };
|
|
2208
|
+
if (this.isProcessAlive(owner.pid)) {
|
|
2209
|
+
// The pid is alive, but nothing here can tell whether it is a broker:
|
|
2210
|
+
// pid numbers are reused, and a renamed machine records its old name.
|
|
2211
|
+
// Refuse, and hand the client the bounded facts its hint needs.
|
|
2212
|
+
const mine = boundedHostname(this.hostname());
|
|
2213
|
+
const message = recorded !== undefined && mine !== undefined && recorded === mine
|
|
2214
|
+
? `A live process (pid ${pid}) recorded on this machine owns this gateway state directory.`
|
|
2215
|
+
: recorded === undefined
|
|
2216
|
+
? `The gateway state lock records pid ${pid} under a machine name that cannot be verified, and a process with that pid is alive here.`
|
|
2217
|
+
: `The gateway state lock records host ${recorded} and pid ${pid}, and a process with that pid is alive here.`;
|
|
2218
|
+
throw new BridgeError("GATEWAY_STATE_IN_USE", message, true, detail);
|
|
2219
|
+
}
|
|
2220
|
+
await this.recoverStaleLock(lockPath, recorded === undefined ? `pid ${pid}, machine name unverifiable` : `recorded host ${recorded}, pid ${pid}`);
|
|
2303
2221
|
}
|
|
2304
2222
|
throw new BridgeError("GATEWAY_STATE_IN_USE", "Could not acquire exclusive gateway controller ownership.", true);
|
|
2305
2223
|
}
|
|
@@ -2339,11 +2257,16 @@ export class GatewayStore {
|
|
|
2339
2257
|
catch {
|
|
2340
2258
|
throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state is not valid JSON.");
|
|
2341
2259
|
}
|
|
2342
|
-
if (isObject(parsed) && Object.hasOwn(parsed, "schemaVersion") && parsed.schemaVersion !==
|
|
2343
|
-
throw new BridgeError("GATEWAY_STATE_SCHEMA_UNSUPPORTED", "The gateway state schema is unsupported. Stop Embassy, move gateway-state.json aside
|
|
2260
|
+
if (isObject(parsed) && Object.hasOwn(parsed, "schemaVersion") && parsed.schemaVersion !== 5) {
|
|
2261
|
+
throw new BridgeError("GATEWAY_STATE_SCHEMA_UNSUPPORTED", "The gateway state schema is unsupported. Stop Embassy, move gateway-state.json aside — nodes.json, if you use federation, is untouched — then restart and re-register Codex tasks.");
|
|
2344
2262
|
}
|
|
2345
|
-
if (!
|
|
2346
|
-
|
|
2263
|
+
if (!isGatewayPersistedStateV5(parsed, this.config.hostId)) {
|
|
2264
|
+
// A document that is a valid v5 state on its own terms but not under this
|
|
2265
|
+
// broker's host identity is not corrupt: it belongs to another host, and
|
|
2266
|
+
// the operator deserves that sentence rather than "failed validation".
|
|
2267
|
+
throw isGatewayPersistedStateV5(parsed)
|
|
2268
|
+
? new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway state exceeds its configured bounds or host allowlist.")
|
|
2269
|
+
: new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict v5 schema validation.");
|
|
2347
2270
|
}
|
|
2348
2271
|
this.assertConfiguredBounds(parsed);
|
|
2349
2272
|
return parsed;
|
|
@@ -2351,7 +2274,7 @@ export class GatewayStore {
|
|
|
2351
2274
|
async persist(force = false) {
|
|
2352
2275
|
if (this.persistenceDeferred && !force)
|
|
2353
2276
|
return;
|
|
2354
|
-
const state = this.requireState();
|
|
2277
|
+
const state = this.persistedProjection(this.requireState());
|
|
2355
2278
|
const temporary = path.join(this.rootDir, `.gateway-state-${randomUUID()}.tmp`);
|
|
2356
2279
|
const body = `${JSON.stringify(state, null, 2)}\n`;
|
|
2357
2280
|
if (Buffer.byteLength(body, "utf8") > GATEWAY_MAX_STATE_FILE_BYTES) {
|
|
@@ -2410,10 +2333,13 @@ export class GatewayStore {
|
|
|
2410
2333
|
}
|
|
2411
2334
|
const installedCurrent = installed?.commit.id === state.commit.id &&
|
|
2412
2335
|
installed.commit.sequence === state.commit.sequence;
|
|
2413
|
-
|
|
2414
|
-
|
|
2336
|
+
// The rename crossed the commit point and the file holds this very
|
|
2337
|
+
// commit: the write succeeded. The live state stays as it is — the
|
|
2338
|
+
// file is its projection, minus the ephemeral rows that must never be
|
|
2339
|
+
// read back in, so installing the readback here would silently turn
|
|
2340
|
+
// every live ephemeral registration into nothing.
|
|
2341
|
+
if (installedCurrent)
|
|
2415
2342
|
return;
|
|
2416
|
-
}
|
|
2417
2343
|
const installedPrior = !readbackFailed &&
|
|
2418
2344
|
(prior === undefined
|
|
2419
2345
|
? installed === undefined
|