agent-embassy 1.8.2 → 1.9.2

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.
Files changed (76) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +23 -3
  3. package/README.zh-CN.md +23 -3
  4. package/SECURITY.md +30 -6
  5. package/dist/src/gateway/cli-copy.en.d.ts +1 -1
  6. package/dist/src/gateway/cli-copy.en.js +8 -0
  7. package/dist/src/gateway/cli-copy.en.js.map +1 -1
  8. package/dist/src/gateway/cli-copy.zh-CN.d.ts +1 -1
  9. package/dist/src/gateway/cli-copy.zh-CN.js +8 -0
  10. package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
  11. package/dist/src/gateway/cli.d.ts +8 -3
  12. package/dist/src/gateway/cli.js +191 -19
  13. package/dist/src/gateway/cli.js.map +1 -1
  14. package/dist/src/gateway/config.d.ts +2 -0
  15. package/dist/src/gateway/config.js +1 -1
  16. package/dist/src/gateway/config.js.map +1 -1
  17. package/dist/src/gateway/control.d.ts +79 -25
  18. package/dist/src/gateway/control.js +98 -18
  19. package/dist/src/gateway/control.js.map +1 -1
  20. package/dist/src/gateway/dashboard-copy.d.ts +1 -1
  21. package/dist/src/gateway/dashboard-copy.en.d.ts +2 -0
  22. package/dist/src/gateway/dashboard-copy.en.js +2 -0
  23. package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
  24. package/dist/src/gateway/dashboard-copy.js +2 -0
  25. package/dist/src/gateway/dashboard-copy.js.map +1 -1
  26. package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +2 -0
  27. package/dist/src/gateway/dashboard-copy.zh-CN.js +2 -0
  28. package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
  29. package/dist/src/gateway/dashboard-model.d.ts +3 -0
  30. package/dist/src/gateway/dashboard-model.js +3 -0
  31. package/dist/src/gateway/dashboard-model.js.map +1 -1
  32. package/dist/src/gateway/federation-nodes.d.ts +18 -0
  33. package/dist/src/gateway/federation-nodes.js +142 -0
  34. package/dist/src/gateway/federation-nodes.js.map +1 -0
  35. package/dist/src/gateway/live-dashboard-app/app.js +5 -4
  36. package/dist/src/gateway/peer-client.d.ts +57 -0
  37. package/dist/src/gateway/peer-client.js +208 -0
  38. package/dist/src/gateway/peer-client.js.map +1 -0
  39. package/dist/src/gateway/peer-mailbox.d.ts +78 -0
  40. package/dist/src/gateway/peer-mailbox.js +168 -0
  41. package/dist/src/gateway/peer-mailbox.js.map +1 -0
  42. package/dist/src/gateway/peer-protocol.d.ts +107 -0
  43. package/dist/src/gateway/peer-protocol.js +72 -0
  44. package/dist/src/gateway/peer-protocol.js.map +1 -0
  45. package/dist/src/gateway/peer-stdio.d.ts +23 -0
  46. package/dist/src/gateway/peer-stdio.js +100 -0
  47. package/dist/src/gateway/peer-stdio.js.map +1 -0
  48. package/dist/src/gateway/provenance-envelope.js +6 -1
  49. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  50. package/dist/src/gateway/providers.d.ts +5 -2
  51. package/dist/src/gateway/providers.js +10 -7
  52. package/dist/src/gateway/providers.js.map +1 -1
  53. package/dist/src/gateway/server.d.ts +4 -1
  54. package/dist/src/gateway/server.js +21 -8
  55. package/dist/src/gateway/server.js.map +1 -1
  56. package/dist/src/gateway/service.d.ts +19 -0
  57. package/dist/src/gateway/service.js +437 -37
  58. package/dist/src/gateway/service.js.map +1 -1
  59. package/dist/src/gateway/state-v2-to-v3.js +1 -0
  60. package/dist/src/gateway/state-v2-to-v3.js.map +1 -1
  61. package/dist/src/gateway/store.d.ts +9 -1
  62. package/dist/src/gateway/store.js +183 -43
  63. package/dist/src/gateway/store.js.map +1 -1
  64. package/dist/src/gateway/types.d.ts +7 -6
  65. package/dist/src/gateway/types.js +5 -10
  66. package/dist/src/gateway/types.js.map +1 -1
  67. package/docs/CONFIGURATION.md +23 -8
  68. package/docs/CONFIGURATION.zh-CN.md +12 -4
  69. package/docs/DASHBOARD.md +2 -2
  70. package/docs/DASHBOARD.zh-CN.md +2 -2
  71. package/docs/DELIVERY.md +3 -1
  72. package/docs/DELIVERY.zh-CN.md +3 -1
  73. package/docs/GATEWAY-ARCHITECTURE.md +6 -4
  74. package/package.json +1 -1
  75. package/skills/embassy-peer/SKILL.md +32 -4
  76. package/skills/embassy-peer/agents/openai.yaml +2 -2
@@ -5,6 +5,7 @@ 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 { peerEdgeRef } from "./peer-protocol.js";
8
9
  import { deliveryStates, directionId, gatewayActivityActions, gatewayActivityKinds, gatewayProviders, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, routeRegistrationModes } from "./types.js";
9
10
  const STATE_MARKER = ".agent-embassy-state";
10
11
  const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
@@ -17,6 +18,7 @@ const DEFAULT_RETAINED_BODY_BYTES = 1 * 1024 * 1024;
17
18
  const ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
18
19
  const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
19
20
  const PRIVATE_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
21
+ const PEER_ROUTE_HANDLE_PATTERN = /^peer:[0-9a-f]{64}$/;
20
22
  const SAFE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
21
23
  const MESSAGE_ID_PATTERN = /^msg_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
24
  const MESSAGE_SUFFIX_PATTERN = /^[0-9a-f]{8}$/;
@@ -103,6 +105,8 @@ function isRoute(value) {
103
105
  isLogicalBinding(value.binding) &&
104
106
  typeof value.registrationMode === "string" &&
105
107
  REGISTRATION_MODES.has(value.registrationMode) &&
108
+ (value.binding.provider !== "peer" || value.registrationMode === "federated_peer" ||
109
+ PEER_ROUTE_HANDLE_PATTERN.test(value.binding.routeHandle)) &&
106
110
  typeof value.enabled === "boolean" &&
107
111
  (value.busyPolicy === "queue" || value.busyPolicy === "refuse") &&
108
112
  isIsoTimestamp(value.registeredAt) &&
@@ -141,11 +145,25 @@ function consentKey(endpoints) {
141
145
  .map((endpoint) => [endpoint.provider, endpoint.alias, endpoint.registrationId].join("\0"))
142
146
  .join("\0");
143
147
  }
148
+ function edgeOwnerHost(endpoints) {
149
+ return endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0];
150
+ }
151
+ function peerMirrorRegistrationId(host, routeRef) {
152
+ return `reg_peer_${createHash("sha256").update(`${host}\0${routeRef}`).digest("base64url").slice(0, 32)}`;
153
+ }
144
154
  function sameConsent(left, right) {
145
155
  if (left === null || right === null)
146
156
  return left === right;
147
157
  return consentKey(left) === consentKey(right);
148
158
  }
159
+ function routeModeMatchesHost(route, localHost) {
160
+ if (route.registrationMode === "federated_peer") {
161
+ return localHost !== undefined && route.binding.hostId !== localHost;
162
+ }
163
+ return route.binding.hostId === localHost && (route.binding.provider === "claude"
164
+ ? route.registrationMode === "selected_live_peer"
165
+ : route.registrationMode === "explicit_opt_in");
166
+ }
149
167
  function isConsentEdge(value) {
150
168
  if (!isObject(value) ||
151
169
  !hasOnlyKeys(value, ["endpoints", "createdAt", "updatedAt", "counters"]) ||
@@ -156,7 +174,7 @@ function isConsentEdge(value) {
156
174
  }
157
175
  const endpoints = value.endpoints;
158
176
  return (compareConsentEndpoints(endpoints[0], endpoints[1]) < 0 &&
159
- endpoints[0].provider !== endpoints[1].provider &&
177
+ endpoints[0].alias !== endpoints[1].alias &&
160
178
  isIsoTimestamp(value.createdAt) &&
161
179
  isIsoTimestamp(value.updatedAt) &&
162
180
  isCounters(value.counters));
@@ -167,7 +185,9 @@ function isPrepared(value) {
167
185
  (value.kind === "claude_mailbox" ||
168
186
  value.kind === "codex_turn_start" ||
169
187
  value.kind === "codex_turn_steer" ||
170
- value.kind === "acp_prompt") &&
188
+ value.kind === "acp_prompt" ||
189
+ value.kind === "peer_mailbox" ||
190
+ value.kind === "peer_handoff") &&
171
191
  isPositiveInteger(value.bodyBytes) &&
172
192
  typeof value.bodySha256 === "string" &&
173
193
  SHA256_PATTERN.test(value.bodySha256) &&
@@ -176,13 +196,17 @@ function isPrepared(value) {
176
196
  typeof value.sha256 === "string" &&
177
197
  SHA256_PATTERN.test(value.sha256));
178
198
  }
179
- function expectedPreparedKind(message) {
199
+ function expectedPreparedKind(message, targetMode) {
200
+ if (targetMode === "federated_peer")
201
+ return "peer_handoff";
180
202
  const target = parseDirection(message.direction).targetProvider;
181
203
  if (target === "claude")
182
204
  return "claude_mailbox";
183
205
  if (target === "codex") {
184
206
  return message.steer === true ? "codex_turn_steer" : "codex_turn_start";
185
207
  }
208
+ if (target === "peer")
209
+ return "peer_mailbox";
186
210
  return "acp_prompt";
187
211
  }
188
212
  function isAttemptAuthority(value) {
@@ -460,11 +484,15 @@ export function isGatewayPersistedStateV3(value) {
460
484
  }
461
485
  }
462
486
  }
487
+ const localHosts = new Set(state.routes
488
+ .filter((route) => route.registrationMode !== "federated_peer")
489
+ .map((route) => route.binding.hostId));
490
+ if (localHosts.size > 1)
491
+ return false;
492
+ const localHost = [...localHosts][0];
463
493
  for (const route of state.routes) {
464
494
  if (!route.alias.endsWith(`@${route.binding.hostId}`) ||
465
- (route.binding.provider === "claude"
466
- ? route.registrationMode !== "selected_live_peer"
467
- : route.registrationMode !== "explicit_opt_in")) {
495
+ !routeModeMatchesHost(route, localHost)) {
468
496
  return false;
469
497
  }
470
498
  const prefix = gatewayRegistrationIngressPrefixes[route.binding.provider];
@@ -479,7 +507,8 @@ export function isGatewayPersistedStateV3(value) {
479
507
  right?.binding.registrationId !== rightEndpoint.registrationId ||
480
508
  left.binding.provider !== leftEndpoint.provider ||
481
509
  right.binding.provider !== rightEndpoint.provider ||
482
- left.binding.hostId !== right.binding.hostId) {
510
+ (left.binding.hostId === right.binding.hostId &&
511
+ left.binding.provider === right.binding.provider)) {
483
512
  return false;
484
513
  }
485
514
  }
@@ -497,7 +526,8 @@ export function isGatewayPersistedStateV3(value) {
497
526
  if (message.sourceRegistrationId !== null) {
498
527
  if (source?.binding.registrationId !== message.sourceRegistrationId ||
499
528
  source.binding.provider !== direction.sourceProvider ||
500
- (target !== undefined && source.binding.hostId !== target.binding.hostId)) {
529
+ (target !== undefined && source.binding.hostId === target.binding.hostId &&
530
+ source.binding.provider === target.binding.provider)) {
501
531
  return false;
502
532
  }
503
533
  }
@@ -537,7 +567,7 @@ export function isGatewayPersistedStateV3(value) {
537
567
  }
538
568
  if ((message.state.phase === "armed" ||
539
569
  message.state.phase === "accepted") &&
540
- (message.state.prepared.kind !== expectedPreparedKind(message) ||
570
+ (message.state.prepared.kind !== expectedPreparedKind(message, target?.registrationMode) ||
541
571
  message.state.prepared.bodyBytes !== message.bytes ||
542
572
  message.state.prepared.bodySha256 !==
543
573
  createHash("sha256").update(message.body, "utf8").digest("hex"))) {
@@ -714,6 +744,9 @@ export class GatewayStore {
714
744
  async listLogicalRoutes() {
715
745
  return this.inspectPrivateRoutes();
716
746
  }
747
+ async inspectPrivateConsentEdges() {
748
+ return this.read((state) => structuredClone(state.consentEdges));
749
+ }
717
750
  async inspectPrivateRoute(alias) {
718
751
  return this.read((state) => {
719
752
  const route = state.routes.find((candidate) => candidate.alias === alias);
@@ -725,6 +758,108 @@ export class GatewayStore {
725
758
  };
726
759
  });
727
760
  }
761
+ async reconcilePeerCatalog(peerHost, catalog) {
762
+ return this.mutate((state, now) => {
763
+ const localHost = this.config.hostId ?? this.config.allowedHosts[0];
764
+ 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)) {
767
+ throw new BridgeError("INVALID_PEER_CATALOG", "The peer catalog is not an exact complete projection for this configured host.");
768
+ }
769
+ const desired = new Map(catalog.routes.map((route) => [route.ref, route]));
770
+ const existing = state.routes.filter((route) => route.registrationMode === "federated_peer" && route.binding.hostId === peerHost);
771
+ const settlements = [];
772
+ for (const route of existing) {
773
+ const row = desired.get(route.binding.routeHandle);
774
+ if (row !== undefined && row.provider === route.binding.provider)
775
+ continue;
776
+ settlements.push(...this.terminalizeRegistration(state, route.binding.registrationId, now));
777
+ this.removeRegistrationMetadata(state, route);
778
+ }
779
+ for (const row of catalog.routes) {
780
+ let route = state.routes.find((candidate) => candidate.registrationMode === "federated_peer" &&
781
+ candidate.binding.hostId === peerHost && candidate.binding.routeHandle === row.ref &&
782
+ candidate.binding.provider === row.provider);
783
+ const collision = state.routes.find((candidate) => candidate.alias === row.alias && candidate !== route);
784
+ if (collision !== undefined)
785
+ throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "A peer catalog alias conflicts with current local authority.");
786
+ if (route === undefined) {
787
+ if (state.routes.length >= this.config.limits.maxRoutes)
788
+ throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
789
+ const input = { alias: row.alias, registrationMode: "federated_peer",
790
+ binding: { provider: row.provider, hostId: peerHost, routeHandle: row.ref,
791
+ registrationId: peerMirrorRegistrationId(peerHost, row.ref) } };
792
+ route = routeRecord(input, now);
793
+ state.routes.push(route);
794
+ }
795
+ else if (route.alias !== row.alias) {
796
+ this.renameRegistrationCoordinates(state, route.alias, row.alias, route.binding.registrationId);
797
+ route.alias = row.alias;
798
+ }
799
+ route.enabled = row.enabled;
800
+ route.updatedAt = now.toISOString();
801
+ }
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
+ 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
+ return { settlements, routes: state.routes.filter((route) => route.registrationMode === "federated_peer" &&
828
+ route.binding.hostId === peerHost).map((route) => ({ alias: route.alias, binding: { ...route.binding },
829
+ registrationMode: route.registrationMode, enabled: route.enabled })) };
830
+ });
831
+ }
832
+ async enqueuePeerHandoff(peerHost, handoff) {
833
+ return this.mutate((state, now) => {
834
+ const localHost = this.config.hostId ?? this.config.allowedHosts[0];
835
+ if (!this.config.allowedHosts.includes(peerHost) || peerHost === localHost || handoff.source.host !== peerHost ||
836
+ handoff.target.host !== localHost || handoff.edgeOwnerHost !== [peerHost, localHost].sort()[0] ||
837
+ handoff.edgeRef !== peerEdgeRef([handoff.source, handoff.target]))
838
+ throw new BridgeError("INVALID_PEER_HANDOFF", "The peer handoff does not match this configured direct link.");
839
+ const source = state.routes.find((route) => route.registrationMode === "federated_peer" && route.binding.hostId === peerHost &&
840
+ route.alias === handoff.source.alias && route.binding.provider === handoff.source.provider && route.binding.routeHandle === handoff.source.routeRef);
841
+ const target = state.routes.find((route) => route.registrationMode !== "federated_peer" && route.binding.hostId === localHost &&
842
+ route.alias === handoff.target.alias && route.binding.provider === handoff.target.provider &&
843
+ route.binding.registrationId === handoff.target.routeRef && route.enabled);
844
+ if (source === undefined || target === undefined)
845
+ 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
+ return this.enqueueResolved(state, now, { body: handoff.body, dedupeKey: `${peerHost}:${handoff.originMessageId}`,
857
+ deadlineAt: handoff.deadlineAt, ...(handoff.conversationCorrelation === undefined ? {} :
858
+ { conversationIdSuffix: handoff.conversationCorrelation }), ...(handoff.steer === true ? { steer: true } : {}) }, { sourceAlias: source.alias, targetAlias: target.alias, direction: directionId(source.binding.provider, target.binding.provider),
859
+ sourceRegistrationId: source.binding.registrationId, targetRegistrationId: target.binding.registrationId,
860
+ consentEdge: edge.endpoints, pair: true, exposeDeliveryToken: false });
861
+ });
862
+ }
728
863
  async registerRoute(input) {
729
864
  await this.mutate((state, now) => {
730
865
  this.validateRouteInput(input);
@@ -909,23 +1044,7 @@ export class GatewayStore {
909
1044
  if (index < 0)
910
1045
  return { settlements: [], unreferencedAliases: [] };
911
1046
  const edge = state.consentEdges[index];
912
- const settlements = [];
913
- for (const message of state.messages) {
914
- if (message.state.phase === "terminal" ||
915
- !sameConsent(message.consentEdge, edge.endpoints)) {
916
- continue;
917
- }
918
- const outcome = message.state.phase === "armed"
919
- ? "ambiguous"
920
- : message.state.phase === "accepted"
921
- ? message.state.lossOutcome
922
- : "cancelled";
923
- settlements.push(this.finishMessage(state, message, outcome, now, outcome === "ambiguous"
924
- ? "DISPATCH_OUTCOME_AMBIGUOUS"
925
- : outcome === "unconfirmed"
926
- ? "DELIVERY_UNCONFIRMED"
927
- : "SENDER_NOT_PAIRED"));
928
- }
1047
+ const settlements = this.terminalizeConsentEdge(state, edge, now);
929
1048
  state.consentEdges.splice(index, 1);
930
1049
  return {
931
1050
  settlements,
@@ -973,9 +1092,11 @@ export class GatewayStore {
973
1092
  target.binding.registrationId !== input.expectedTargetRegistrationId) {
974
1093
  throw new BridgeError("ROUTE_UNREGISTERED", "The native ingress target registration is no longer current.");
975
1094
  }
1095
+ const nativeSourceHost = target.registrationMode === "federated_peer"
1096
+ ? (this.config.hostId ?? this.config.allowedHosts[0]) : target.binding.hostId;
976
1097
  if (input.source.binding.provider !== "claude" ||
977
- input.source.binding.hostId !== target.binding.hostId ||
978
- input.source.alias.endsWith(`@${target.binding.hostId}`) === false) {
1098
+ input.source.binding.hostId !== nativeSourceHost ||
1099
+ input.source.alias.endsWith(`@${nativeSourceHost}`) === false) {
979
1100
  throw new BridgeError("INVALID_NATIVE_PEER", "The native Claude sender does not match the target host.");
980
1101
  }
981
1102
  const selected = state.routes.find((route) => route.alias === input.source.alias &&
@@ -985,7 +1106,7 @@ export class GatewayStore {
985
1106
  const edge = selected === undefined
986
1107
  ? undefined
987
1108
  : state.consentEdges.find((candidate) => sameConsent(candidate.endpoints, canonicalConsentEndpoints(selected, target)));
988
- if (this.config.inboundMode === "paired" && edge === undefined) {
1109
+ if ((this.config.inboundMode === "paired" || target.registrationMode === "federated_peer") && edge === undefined) {
989
1110
  this.recordRejection(state, {
990
1111
  sourceAlias: input.source.alias, targetAlias: target.alias,
991
1112
  direction: directionId("claude", target.binding.provider),
@@ -1137,15 +1258,15 @@ export class GatewayStore {
1137
1258
  const bodySha256 = createHash("sha256")
1138
1259
  .update(message.body ?? "", "utf8")
1139
1260
  .digest("hex");
1261
+ const target = state.routes.find((route) => route.alias === message.targetAlias &&
1262
+ route.binding.registrationId === message.targetRegistrationId &&
1263
+ route.enabled);
1140
1264
  if (!isPrepared(input.prepared) ||
1141
- input.prepared.kind !== expectedPreparedKind(message) ||
1265
+ input.prepared.kind !== expectedPreparedKind(message, target?.registrationMode) ||
1142
1266
  input.prepared.bodyBytes !== message.bytes ||
1143
1267
  input.prepared.bodySha256 !== bodySha256) {
1144
1268
  throw new BridgeError("INVALID_PREPARED_WRITE_EVIDENCE", "The prepared payload evidence does not match the admitted message.");
1145
1269
  }
1146
- const target = state.routes.find((route) => route.alias === message.targetAlias &&
1147
- route.binding.registrationId === message.targetRegistrationId &&
1148
- route.enabled);
1149
1270
  if (input.sourceRegistrationId !== message.sourceRegistrationId ||
1150
1271
  input.targetRegistrationId !== message.targetRegistrationId ||
1151
1272
  (message.transientTarget !== true && target === undefined)) {
@@ -1228,8 +1349,8 @@ export class GatewayStore {
1228
1349
  if ((message.state.phase === "armed" &&
1229
1350
  (input.state === "expired" ||
1230
1351
  (input.state === "unconfirmed" &&
1231
- (input.safeErrorCode !== "ACP_OUTCOME_COARSE" ||
1232
- message.state.prepared.kind !== "acp_prompt")))) ||
1352
+ !((input.safeErrorCode === "ACP_OUTCOME_COARSE" && message.state.prepared.kind === "acp_prompt") ||
1353
+ (input.safeErrorCode === "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" && message.state.prepared.kind === "peer_handoff"))))) ||
1233
1354
  (message.state.phase === "accepted" &&
1234
1355
  (input.state === "expired" ||
1235
1356
  (input.state === "unconfirmed" &&
@@ -1314,6 +1435,7 @@ export class GatewayStore {
1314
1435
  ? {}
1315
1436
  : { oldestQueuedAt: queued[0].enqueuedAt }),
1316
1437
  counters: { ...route.counters },
1438
+ mutable: route.registrationMode !== "federated_peer",
1317
1439
  };
1318
1440
  });
1319
1441
  const consentEdges = state.consentEdges.map((edge) => ({
@@ -1325,7 +1447,8 @@ export class GatewayStore {
1325
1447
  alias: edge.endpoints[1].alias, provider: edge.endpoints[1].provider,
1326
1448
  },
1327
1449
  ],
1328
- host: aliasHost(edge.endpoints[0].alias), counters: { ...edge.counters },
1450
+ host: edgeOwnerHost(edge.endpoints), counters: { ...edge.counters },
1451
+ mutable: edgeOwnerHost(edge.endpoints) === (this.config.hostId ?? this.config.allowedHosts[0]),
1329
1452
  }));
1330
1453
  const currentEvents = state.messages.map((message) => this.projectMessageEvent(message));
1331
1454
  const legacyEvents = state.activity
@@ -1422,15 +1545,16 @@ export class GatewayStore {
1422
1545
  };
1423
1546
  }
1424
1547
  validateRouteInput(input) {
1548
+ const localHost = this.config.hostId ?? this.config.allowedHosts[0];
1425
1549
  if (!isObject(input) ||
1426
1550
  !ALIAS_PATTERN.test(input.alias) ||
1427
1551
  !isLogicalBinding(input.binding) ||
1428
1552
  !REGISTRATION_MODES.has(input.registrationMode) ||
1553
+ (input.binding.provider === "peer" && input.registrationMode !== "federated_peer" &&
1554
+ !PEER_ROUTE_HANDLE_PATTERN.test(input.binding.routeHandle)) ||
1429
1555
  !input.alias.endsWith(`@${input.binding.hostId}`) ||
1430
1556
  !this.config.allowedHosts.includes(input.binding.hostId) ||
1431
- (input.binding.provider === "claude"
1432
- ? input.registrationMode !== "selected_live_peer"
1433
- : input.registrationMode !== "explicit_opt_in")) {
1557
+ !routeModeMatchesHost(input, localHost)) {
1434
1558
  throw new BridgeError("INVALID_ROUTE_BINDING", "The logical route binding is invalid for this gateway.");
1435
1559
  }
1436
1560
  const prefix = gatewayRegistrationIngressPrefixes[input.binding.provider];
@@ -1469,10 +1593,10 @@ export class GatewayStore {
1469
1593
  }
1470
1594
  if (left === undefined ||
1471
1595
  right === undefined ||
1472
- left.binding.provider === right.binding.provider ||
1473
- left.binding.hostId !== right.binding.hostId) {
1596
+ (left.binding.provider === right.binding.provider &&
1597
+ left.binding.hostId === right.binding.hostId)) {
1474
1598
  if (required) {
1475
- throw new BridgeError("INVALID_CONSENT_EDGE", "Consent requires exact same-host routes from distinct providers.");
1599
+ throw new BridgeError("INVALID_CONSENT_EDGE", "Consent requires distinct providers or distinct configured hosts.");
1476
1600
  }
1477
1601
  return undefined;
1478
1602
  }
@@ -1633,6 +1757,16 @@ export class GatewayStore {
1633
1757
  : { supersededSettlement }),
1634
1758
  };
1635
1759
  }
1760
+ terminalizeConsentEdge(state, edge, now) {
1761
+ return state.messages.filter((message) => message.state.phase !== "terminal" &&
1762
+ sameConsent(message.consentEdge, edge.endpoints)).map((message) => {
1763
+ const outcome = message.state.phase === "armed" ? "ambiguous" :
1764
+ message.state.phase === "accepted" ? message.state.lossOutcome : "cancelled";
1765
+ return this.finishMessage(state, message, outcome, now, outcome === "ambiguous"
1766
+ ? "DISPATCH_OUTCOME_AMBIGUOUS" : outcome === "unconfirmed"
1767
+ ? "DELIVERY_UNCONFIRMED" : "SENDER_NOT_PAIRED");
1768
+ });
1769
+ }
1636
1770
  allocateDeliveryToken(state) {
1637
1771
  for (let attempt = 0; attempt < 8; attempt += 1) {
1638
1772
  const token = `dlv_${createHash("sha256")
@@ -1709,11 +1843,16 @@ export class GatewayStore {
1709
1843
  : { safeErrorCode: message.state.safeErrorCode }),
1710
1844
  };
1711
1845
  }
1846
+ const handedOff = outcome === "delivered" &&
1847
+ (message.state.phase === "armed" || message.state.phase === "accepted") &&
1848
+ message.state.prepared.kind === "peer_handoff";
1712
1849
  if (message.state.phase === "queued") {
1713
1850
  state.accounting.queuedBytes -= message.bytes;
1714
1851
  }
1715
1852
  message.state = terminalState(outcome, now, message.enqueuedAt, safeErrorCode);
1716
1853
  this.touchMessage(state, message);
1854
+ if (handedOff)
1855
+ delete message.body;
1717
1856
  state.accounting[outcome] += 1;
1718
1857
  const target = state.routes.find((route) => route.binding.registrationId === message.targetRegistrationId);
1719
1858
  if (target !== undefined)
@@ -1911,7 +2050,8 @@ export class GatewayStore {
1911
2050
  }
1912
2051
  assertConfiguredBounds(state) {
1913
2052
  const active = state.messages.filter((message) => message.state.phase !== "terminal");
1914
- if (state.routes.some((route) => !this.config.allowedHosts.includes(route.binding.hostId)) ||
2053
+ if (state.routes.some((route) => !this.config.allowedHosts.includes(route.binding.hostId) ||
2054
+ !routeModeMatchesHost(route, this.config.hostId ?? this.config.allowedHosts[0])) ||
1915
2055
  state.routes.length > this.config.limits.maxRoutes ||
1916
2056
  state.consentEdges.length > this.config.limits.maxConsentEdges ||
1917
2057
  active.length > this.config.limits.maxQueueMessages ||