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.
- package/CHANGELOG.md +18 -0
- package/README.md +23 -3
- package/README.zh-CN.md +23 -3
- package/SECURITY.md +30 -6
- package/dist/src/gateway/cli-copy.en.d.ts +1 -1
- package/dist/src/gateway/cli-copy.en.js +8 -0
- package/dist/src/gateway/cli-copy.en.js.map +1 -1
- package/dist/src/gateway/cli-copy.zh-CN.d.ts +1 -1
- package/dist/src/gateway/cli-copy.zh-CN.js +8 -0
- package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -1
- package/dist/src/gateway/cli.d.ts +8 -3
- package/dist/src/gateway/cli.js +191 -19
- package/dist/src/gateway/cli.js.map +1 -1
- package/dist/src/gateway/config.d.ts +2 -0
- package/dist/src/gateway/config.js +1 -1
- package/dist/src/gateway/config.js.map +1 -1
- package/dist/src/gateway/control.d.ts +79 -25
- package/dist/src/gateway/control.js +98 -18
- package/dist/src/gateway/control.js.map +1 -1
- package/dist/src/gateway/dashboard-copy.d.ts +1 -1
- package/dist/src/gateway/dashboard-copy.en.d.ts +2 -0
- package/dist/src/gateway/dashboard-copy.en.js +2 -0
- package/dist/src/gateway/dashboard-copy.en.js.map +1 -1
- package/dist/src/gateway/dashboard-copy.js +2 -0
- package/dist/src/gateway/dashboard-copy.js.map +1 -1
- package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +2 -0
- package/dist/src/gateway/dashboard-copy.zh-CN.js +2 -0
- package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -1
- package/dist/src/gateway/dashboard-model.d.ts +3 -0
- package/dist/src/gateway/dashboard-model.js +3 -0
- package/dist/src/gateway/dashboard-model.js.map +1 -1
- package/dist/src/gateway/federation-nodes.d.ts +18 -0
- package/dist/src/gateway/federation-nodes.js +142 -0
- package/dist/src/gateway/federation-nodes.js.map +1 -0
- package/dist/src/gateway/live-dashboard-app/app.js +5 -4
- package/dist/src/gateway/peer-client.d.ts +57 -0
- package/dist/src/gateway/peer-client.js +208 -0
- package/dist/src/gateway/peer-client.js.map +1 -0
- package/dist/src/gateway/peer-mailbox.d.ts +78 -0
- package/dist/src/gateway/peer-mailbox.js +168 -0
- package/dist/src/gateway/peer-mailbox.js.map +1 -0
- package/dist/src/gateway/peer-protocol.d.ts +107 -0
- package/dist/src/gateway/peer-protocol.js +72 -0
- package/dist/src/gateway/peer-protocol.js.map +1 -0
- package/dist/src/gateway/peer-stdio.d.ts +23 -0
- package/dist/src/gateway/peer-stdio.js +100 -0
- package/dist/src/gateway/peer-stdio.js.map +1 -0
- package/dist/src/gateway/provenance-envelope.js +6 -1
- package/dist/src/gateway/provenance-envelope.js.map +1 -1
- package/dist/src/gateway/providers.d.ts +5 -2
- package/dist/src/gateway/providers.js +10 -7
- package/dist/src/gateway/providers.js.map +1 -1
- package/dist/src/gateway/server.d.ts +4 -1
- package/dist/src/gateway/server.js +21 -8
- package/dist/src/gateway/server.js.map +1 -1
- package/dist/src/gateway/service.d.ts +19 -0
- package/dist/src/gateway/service.js +437 -37
- package/dist/src/gateway/service.js.map +1 -1
- package/dist/src/gateway/state-v2-to-v3.js +1 -0
- package/dist/src/gateway/state-v2-to-v3.js.map +1 -1
- package/dist/src/gateway/store.d.ts +9 -1
- package/dist/src/gateway/store.js +183 -43
- package/dist/src/gateway/store.js.map +1 -1
- package/dist/src/gateway/types.d.ts +7 -6
- package/dist/src/gateway/types.js +5 -10
- package/dist/src/gateway/types.js.map +1 -1
- package/docs/CONFIGURATION.md +23 -8
- package/docs/CONFIGURATION.zh-CN.md +12 -4
- package/docs/DASHBOARD.md +2 -2
- package/docs/DASHBOARD.zh-CN.md +2 -2
- package/docs/DELIVERY.md +3 -1
- package/docs/DELIVERY.zh-CN.md +3 -1
- package/docs/GATEWAY-ARCHITECTURE.md +6 -4
- package/package.json +1 -1
- package/skills/embassy-peer/SKILL.md +32 -4
- package/skills/embassy-peer/agents/openai.yaml +2 -2
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { BridgeError } from "../errors.js";
|
|
3
3
|
import { createGatewayConversationId, startGatewayControlServer, pairParamAliases, pairParamThreadAttestation, } from "./control.js";
|
|
4
4
|
import { publishGatewayDashboard } from "./dashboard.js";
|
|
5
|
+
import { spawnPeerClient } from "./peer-client.js";
|
|
6
|
+
import { peerEdgeRef } from "./peer-protocol.js";
|
|
5
7
|
import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_DEFAULT_IDLE_MS, PROGRESS_WATCH_HARD_CAPACITY, commitProgressWatchNudge, createProgressWatch, deferProgressWatchNudge, inspectProgressWatchDue, recordProgressWatchActivity, } from "./progress-watch-machine.js";
|
|
6
8
|
import { GatewayStore } from "./store.js";
|
|
7
9
|
import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, } from "./types.js";
|
|
8
10
|
const SAFE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
9
11
|
const PRIVATE_HANDLE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
10
12
|
const PUBLIC_ALIAS = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
|
|
13
|
+
const PEER_TOKEN = /^peer_[A-Za-z0-9_-]{32}$/;
|
|
11
14
|
const DISCOVERY_INTERVAL_MS = 30_000;
|
|
12
15
|
const CODEX_DOCTOR_INTERVAL_MS = 30_000;
|
|
16
|
+
const PEER_REFRESH_INTERVAL_MS = 30_000;
|
|
13
17
|
const CLEAN_RETRY_DELAY_MS = 500;
|
|
14
18
|
const MAX_CONVERSATIONS = 1_024;
|
|
15
19
|
const MAX_PENDING_CLAUDE_REPLIES = MAX_CONVERSATIONS;
|
|
@@ -24,6 +28,7 @@ const CLEAN_RETRY_CODES = new Set([
|
|
|
24
28
|
"SPAWN_TIMEOUT",
|
|
25
29
|
"TRANSPORT_CONNECT_FAILED",
|
|
26
30
|
"REQUEST_TIMEOUT",
|
|
31
|
+
"PEER_NOT_AWAITING",
|
|
27
32
|
]);
|
|
28
33
|
function registrationId() {
|
|
29
34
|
return `reg_${randomBytes(18).toString("base64url")}`;
|
|
@@ -34,7 +39,16 @@ function aliasHost(alias) {
|
|
|
34
39
|
function safeCode(value, fallback) {
|
|
35
40
|
return value !== undefined && SAFE_CODE.test(value) ? value : fallback;
|
|
36
41
|
}
|
|
37
|
-
|
|
42
|
+
class ConsentOwnerError extends BridgeError {
|
|
43
|
+
ownerHost;
|
|
44
|
+
constructor(ownerHost) {
|
|
45
|
+
super("CONSENT_OWNER_HOST_REQUIRED", `Run pair or unpair on the edge owner host ${ownerHost}.`);
|
|
46
|
+
this.ownerHost = ownerHost;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function decisionFor(error, peerPrincipal = false) {
|
|
50
|
+
if (error instanceof ConsentOwnerError)
|
|
51
|
+
return { accepted: false, code: "conflict", ownerHost: error.ownerHost };
|
|
38
52
|
if (!(error instanceof BridgeError))
|
|
39
53
|
return { accepted: false, code: "rejected" };
|
|
40
54
|
if (error.code.includes("NOT_FOUND") || error.code.includes("NOT_AVAILABLE")) {
|
|
@@ -49,7 +63,7 @@ function decisionFor(error) {
|
|
|
49
63
|
if (error.code.includes("UNAVAILABLE") || error.code.includes("OFFLINE") || error.code.includes("UNOBSERVED")) {
|
|
50
64
|
return { accepted: false, code: "unavailable" };
|
|
51
65
|
}
|
|
52
|
-
if (error.code.includes("MISMATCH") || error.code.includes("ADDRESS")) {
|
|
66
|
+
if ((peerPrincipal && error.code === "ROUTE_UNREGISTERED") || error.code.includes("MISMATCH") || error.code.includes("ADDRESS")) {
|
|
53
67
|
return { accepted: false, code: "route_mismatch" };
|
|
54
68
|
}
|
|
55
69
|
return { accepted: false, code: "rejected" };
|
|
@@ -81,6 +95,26 @@ function conversationIdForSuffix(suffix) {
|
|
|
81
95
|
function bodyHash(body) {
|
|
82
96
|
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
83
97
|
}
|
|
98
|
+
function peerCatalogAuthorityChanged(prior, current) {
|
|
99
|
+
const authority = (catalog) => JSON.stringify([
|
|
100
|
+
catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
101
|
+
[...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
102
|
+
]);
|
|
103
|
+
return prior === undefined || authority(prior) !== authority(current);
|
|
104
|
+
}
|
|
105
|
+
function peerCatalogViewChanged(prior, current) {
|
|
106
|
+
const view = (catalog) => JSON.stringify([
|
|
107
|
+
catalog.health,
|
|
108
|
+
catalog.connectors.map(({ lastSeenAt: _seen, observationAgeMs: _age, ...row }) => row),
|
|
109
|
+
catalog.routes.map(({ lastSeenAt: _seen, ...row }) => row).sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
110
|
+
[...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
111
|
+
catalog.alerts.map(({ timestamp: _timestamp, ...row }) => row),
|
|
112
|
+
]);
|
|
113
|
+
return prior === undefined || view(prior) !== view(current);
|
|
114
|
+
}
|
|
115
|
+
function peerHandle(uid, alias, token) {
|
|
116
|
+
return `peer:${createHash("sha256").update(`${uid}\0${alias}\0${token}`).digest("hex")}`;
|
|
117
|
+
}
|
|
84
118
|
export class GatewayService {
|
|
85
119
|
config;
|
|
86
120
|
store;
|
|
@@ -90,6 +124,12 @@ export class GatewayService {
|
|
|
90
124
|
timers;
|
|
91
125
|
nativePeerCwd;
|
|
92
126
|
codexDoctor;
|
|
127
|
+
spawnPeer;
|
|
128
|
+
peerClients = new Map();
|
|
129
|
+
peerCatalogs = new Map();
|
|
130
|
+
peerCleanupAliases = new Set();
|
|
131
|
+
/** View-only freshness; never consulted by routing or write authorization. */
|
|
132
|
+
peerRouteViews = new Map();
|
|
93
133
|
connectors = new Map();
|
|
94
134
|
routeObservations = new Map();
|
|
95
135
|
candidates = new Map();
|
|
@@ -98,6 +138,7 @@ export class GatewayService {
|
|
|
98
138
|
activeAttempts = new Map();
|
|
99
139
|
reserveOperations = new Set();
|
|
100
140
|
inboundOperations = new Set();
|
|
141
|
+
peerRefreshOperations = new Set();
|
|
101
142
|
pendingClaudeReplies = new Map();
|
|
102
143
|
nativeReceipts = new Map();
|
|
103
144
|
progressWatches = new Map();
|
|
@@ -110,6 +151,7 @@ export class GatewayService {
|
|
|
110
151
|
wakeTimer;
|
|
111
152
|
nextDiscoveryAt = 0;
|
|
112
153
|
nextDoctorAt = 0;
|
|
154
|
+
nextPeerRefreshAt = 0;
|
|
113
155
|
codexDoctorResult;
|
|
114
156
|
revision = 0;
|
|
115
157
|
snapshotRevision = 0;
|
|
@@ -125,6 +167,7 @@ export class GatewayService {
|
|
|
125
167
|
this.now = options.now ?? (() => new Date());
|
|
126
168
|
this.nativePeerCwd = options.nativePeerCwd ?? process.cwd();
|
|
127
169
|
this.codexDoctor = options.codexDoctor;
|
|
170
|
+
this.spawnPeer = options.spawnPeer ?? spawnPeerClient;
|
|
128
171
|
this.timers = options.timers ?? {
|
|
129
172
|
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
130
173
|
clearTimeout: (timer) => clearTimeout(timer),
|
|
@@ -188,6 +231,7 @@ export class GatewayService {
|
|
|
188
231
|
const now = this.now().getTime();
|
|
189
232
|
this.nextDiscoveryAt = now + DISCOVERY_INTERVAL_MS;
|
|
190
233
|
this.nextDoctorAt = now + CODEX_DOCTOR_INTERVAL_MS;
|
|
234
|
+
this.nextPeerRefreshAt = (this.config.peerNodes?.length ?? 0) > 0 ? now : 0;
|
|
191
235
|
for (const target of await this.store.inspectDispatchableTargets())
|
|
192
236
|
this.kick(target);
|
|
193
237
|
await this.publish();
|
|
@@ -239,6 +283,12 @@ export class GatewayService {
|
|
|
239
283
|
const failures = [];
|
|
240
284
|
if (control !== undefined)
|
|
241
285
|
await control.close().catch((error) => failures.push(error));
|
|
286
|
+
for (const peer of this.peerClients.values())
|
|
287
|
+
peer.close();
|
|
288
|
+
this.peerClients.clear();
|
|
289
|
+
await Promise.all([...this.peerRefreshOperations]).catch((error) => failures.push(error));
|
|
290
|
+
this.peerCatalogs.clear();
|
|
291
|
+
this.peerRouteViews.clear();
|
|
242
292
|
// Provider close aborts exact owned operations. It must never join a turn.
|
|
243
293
|
await Promise.all(this.adapters.map(async (adapter) => {
|
|
244
294
|
await adapter.close().catch((error) => failures.push(error));
|
|
@@ -252,7 +302,7 @@ export class GatewayService {
|
|
|
252
302
|
}
|
|
253
303
|
}
|
|
254
304
|
handlers() {
|
|
255
|
-
const decide = async (operation) => {
|
|
305
|
+
const decide = async (operation, peerPrincipal = false) => {
|
|
256
306
|
try {
|
|
257
307
|
this.assertWritable();
|
|
258
308
|
await operation();
|
|
@@ -261,12 +311,27 @@ export class GatewayService {
|
|
|
261
311
|
return { accepted: true, code: "ok" };
|
|
262
312
|
}
|
|
263
313
|
catch (error) {
|
|
264
|
-
return decisionFor(error);
|
|
314
|
+
return decisionFor(error, peerPrincipal);
|
|
265
315
|
}
|
|
266
316
|
};
|
|
267
317
|
return {
|
|
268
318
|
health: () => ({ status: this.health(), revision: this.revision }),
|
|
269
319
|
registerCodex: (params) => decide(async () => this.registerCodex(params)),
|
|
320
|
+
registerPeer: async (params) => {
|
|
321
|
+
try {
|
|
322
|
+
this.assertWritable();
|
|
323
|
+
const token = await this.registerPeer(params);
|
|
324
|
+
this.revision += 1;
|
|
325
|
+
await this.publish();
|
|
326
|
+
return token === undefined ? { accepted: true, code: "ok" } : { accepted: true, code: "ok", token };
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
return decisionFor(error, true);
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
unregisterPeer: (params) => decide(async () => this.unregisterPeer(params), true),
|
|
333
|
+
awaitPeer: (params) => this.awaitPeer(params),
|
|
334
|
+
peerReceipt: (params) => decide(async () => this.peerReceipt(params), true),
|
|
270
335
|
unregisterCodex: (params) => decide(async () => this.unregisterCodex(params)),
|
|
271
336
|
removeCodexRegistration: (params) => decide(async () => this.removeCodexRegistration(params.alias)),
|
|
272
337
|
selectClaude: (params) => decide(async () => this.selectClaude(params)),
|
|
@@ -289,12 +354,81 @@ export class GatewayService {
|
|
|
289
354
|
await this.publish();
|
|
290
355
|
return { accepted: true, code: "ok", revision: this.revision };
|
|
291
356
|
},
|
|
357
|
+
peerCatalog: ({ peerHost }) => this.buildPeerCatalog(peerHost),
|
|
358
|
+
peerHandoff: ({ peerHost, handoff }) => this.receivePeerHandoff(peerHost, handoff),
|
|
292
359
|
};
|
|
293
360
|
}
|
|
361
|
+
async buildPeerCatalog(peerHost) {
|
|
362
|
+
const localHost = this.config.hostId ?? this.config.allowedHosts[0];
|
|
363
|
+
if (!(this.config.peerNodes ?? []).includes(peerHost))
|
|
364
|
+
throw new BridgeError("PEER_NOT_CONFIGURED", "The requested peer host is not configured.");
|
|
365
|
+
const [snapshot, privateRoutes, privateEdges] = await Promise.all([
|
|
366
|
+
this.snapshot(), this.store.inspectPrivateRoutes(), this.store.inspectPrivateConsentEdges(),
|
|
367
|
+
]);
|
|
368
|
+
const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" && route.binding.hostId === localHost);
|
|
369
|
+
const publicRoutes = new Map(snapshot.routes.map((route) => [route.alias, route]));
|
|
370
|
+
const routeByAlias = new Map(privateRoutes.map((route) => [route.alias, route]));
|
|
371
|
+
const routes = localRoutes.map((route) => {
|
|
372
|
+
const row = publicRoutes.get(route.alias);
|
|
373
|
+
return {
|
|
374
|
+
ref: route.binding.registrationId, alias: route.alias, provider: route.binding.provider, host: localHost,
|
|
375
|
+
enabled: route.enabled, state: row?.state ?? (route.enabled ? "stale" : "disabled"),
|
|
376
|
+
queueDepth: row?.queueDepth ?? 0, ...(row?.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
|
|
377
|
+
...(row?.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
|
|
378
|
+
};
|
|
379
|
+
});
|
|
380
|
+
const consentEdges = privateEdges.flatMap((edge) => {
|
|
381
|
+
if (edge.endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0] !== localHost)
|
|
382
|
+
return [];
|
|
383
|
+
const rows = edge.endpoints.map((endpoint) => routeByAlias.get(endpoint.alias));
|
|
384
|
+
if (rows[0] === undefined || rows[1] === undefined ||
|
|
385
|
+
!rows.some((route) => route?.binding.hostId === peerHost) ||
|
|
386
|
+
!rows.some((route) => route?.binding.hostId === localHost))
|
|
387
|
+
return [];
|
|
388
|
+
const endpoint = (route) => ({ alias: route.alias, provider: route.binding.provider,
|
|
389
|
+
host: route.binding.hostId, routeRef: route.registrationMode === "federated_peer"
|
|
390
|
+
? route.binding.routeHandle : route.binding.registrationId });
|
|
391
|
+
const endpoints = [endpoint(rows[0]), endpoint(rows[1])];
|
|
392
|
+
return [{ ref: peerEdgeRef(endpoints), ownerHost: localHost, endpoints }];
|
|
393
|
+
});
|
|
394
|
+
return { revision: this.revision, complete: true, truncated: false, generatedAt: snapshot.generatedAt,
|
|
395
|
+
health: snapshot.health, connectors: snapshot.connectors.filter((row) => row.host === localHost).map((row) => ({
|
|
396
|
+
provider: row.provider, host: row.host, health: row.health, protocol: row.protocol, protocolVersion: row.protocolVersion,
|
|
397
|
+
...(row.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
|
|
398
|
+
...(row.observationAgeMs === undefined ? {} : { observationAgeMs: row.observationAgeMs }),
|
|
399
|
+
...(row.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
|
|
400
|
+
})), routes, consentEdges, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
|
|
401
|
+
}
|
|
402
|
+
async receivePeerHandoff(peerHost, handoff) {
|
|
403
|
+
if (!(this.config.peerNodes ?? []).includes(peerHost))
|
|
404
|
+
throw new BridgeError("PEER_NOT_CONFIGURED", "The sending peer host is not configured.");
|
|
405
|
+
const enqueued = await this.store.enqueuePeerHandoff(peerHost, handoff);
|
|
406
|
+
if (enqueued.messageId !== undefined) {
|
|
407
|
+
const source = await this.store.inspectPrivateRoute(handoff.source.alias);
|
|
408
|
+
const target = await this.store.inspectPrivateRoute(handoff.target.alias);
|
|
409
|
+
if (source === undefined || target === undefined)
|
|
410
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The committed peer handoff endpoint is no longer current.");
|
|
411
|
+
const conversationId = conversationIdForSuffix(handoff.conversationCorrelation);
|
|
412
|
+
this.rememberConversation({ id: conversationId, sourceAlias: source.alias, targetAlias: target.alias,
|
|
413
|
+
sourceBinding: source.binding, targetBinding: target.binding, expectsReply: handoff.expectsReply,
|
|
414
|
+
pair: true, nextSequence: 1 });
|
|
415
|
+
this.messageContexts.set(enqueued.messageId, { conversationId, expectsReply: handoff.expectsReply });
|
|
416
|
+
this.kick(target.alias, target.binding.registrationId);
|
|
417
|
+
}
|
|
418
|
+
this.revision += 1;
|
|
419
|
+
await this.publish();
|
|
420
|
+
return { accepted: true };
|
|
421
|
+
}
|
|
294
422
|
async snapshot() {
|
|
295
423
|
const base = await this.store.publicSnapshot();
|
|
296
424
|
const now = this.now();
|
|
297
425
|
const routes = base.routes.map((route) => {
|
|
426
|
+
const peer = this.peerRouteViews.get(route.alias);
|
|
427
|
+
if (peer !== undefined) {
|
|
428
|
+
const age = now.getTime() - Date.parse(peer.observedAt);
|
|
429
|
+
return { ...route, state: peer.state === "unobserved" || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS ? "stale" : peer.state,
|
|
430
|
+
lastSeenAt: peer.observedAt, ...(peer.safeErrorCode === undefined ? {} : { safeErrorCode: peer.safeErrorCode }) };
|
|
431
|
+
}
|
|
298
432
|
const persisted = this.routeObservations.get(route.alias);
|
|
299
433
|
if (persisted === undefined)
|
|
300
434
|
return route;
|
|
@@ -420,6 +554,9 @@ export class GatewayService {
|
|
|
420
554
|
}
|
|
421
555
|
async observeLoadedRoutes() {
|
|
422
556
|
for (const route of await this.store.listLogicalRoutes()) {
|
|
557
|
+
if (route.registrationMode === "federated_peer")
|
|
558
|
+
this.peerRouteViews.set(route.alias, { route: route.binding,
|
|
559
|
+
state: "unobserved", observedAt: this.now().toISOString(), safeErrorCode: "PEER_TUNNEL_UNAVAILABLE" });
|
|
423
560
|
this.observeRoute(route);
|
|
424
561
|
if (route.binding.provider !== "claude") {
|
|
425
562
|
this.reconcileAdvertisement(route);
|
|
@@ -456,20 +593,24 @@ export class GatewayService {
|
|
|
456
593
|
return this.adapterFor({ provider: "claude", hostId });
|
|
457
594
|
}
|
|
458
595
|
async advertise(route) {
|
|
459
|
-
|
|
596
|
+
const hostId = route.registrationMode === "federated_peer"
|
|
597
|
+
? (this.config.hostId ?? this.config.allowedHosts[0]) : route.binding.hostId;
|
|
598
|
+
await this.claudeAdapter(hostId)?.advertiseNativeSourcePeer?.({
|
|
460
599
|
alias: route.alias,
|
|
461
600
|
sourceProvider: route.binding.provider,
|
|
462
601
|
cwd: this.nativePeerCwd,
|
|
463
602
|
});
|
|
464
603
|
}
|
|
465
604
|
async unadvertise(route) {
|
|
466
|
-
|
|
605
|
+
const hostId = route.registrationMode === "federated_peer"
|
|
606
|
+
? (this.config.hostId ?? this.config.allowedHosts[0]) : route.binding.hostId;
|
|
607
|
+
await this.claudeAdapter(hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
|
|
467
608
|
}
|
|
468
609
|
reconcileAdvertisement(route) {
|
|
469
610
|
void this.advertise(route).catch((error) => this.alert("NATIVE_ADVERTISEMENT_FAILED", route, error));
|
|
470
611
|
}
|
|
471
|
-
reconcileUnadvertisement(route) {
|
|
472
|
-
|
|
612
|
+
async reconcileUnadvertisement(route) {
|
|
613
|
+
await this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
|
|
473
614
|
}
|
|
474
615
|
async removeOwnedRoute(route, notFoundCode, releaseProviderRoute) {
|
|
475
616
|
this.assertWritable();
|
|
@@ -487,7 +628,7 @@ export class GatewayService {
|
|
|
487
628
|
await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
|
|
488
629
|
}
|
|
489
630
|
else {
|
|
490
|
-
this.reconcileUnadvertisement(route);
|
|
631
|
+
await this.reconcileUnadvertisement(route);
|
|
491
632
|
}
|
|
492
633
|
}
|
|
493
634
|
async recordActivity(kind, action, aliases, operatorAction) {
|
|
@@ -559,6 +700,79 @@ export class GatewayService {
|
|
|
559
700
|
this.reconcileAdvertisement(installed);
|
|
560
701
|
this.kick(params.alias);
|
|
561
702
|
}
|
|
703
|
+
async registerPeer(params) {
|
|
704
|
+
if (this.peerCleanupAliases.has(params.alias)) {
|
|
705
|
+
throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
|
|
706
|
+
}
|
|
707
|
+
const existing = await this.store.inspectPrivateRoute(params.alias);
|
|
708
|
+
if (existing !== undefined) {
|
|
709
|
+
if (params.token === undefined)
|
|
710
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
711
|
+
await this.assertPeer({ alias: params.alias, token: params.token });
|
|
712
|
+
this.observeRoute(existing);
|
|
713
|
+
this.reconcileAdvertisement(existing);
|
|
714
|
+
this.kick(existing.alias, existing.binding.registrationId);
|
|
715
|
+
return undefined;
|
|
716
|
+
}
|
|
717
|
+
if (params.token !== undefined || !params.alias.endsWith(`@${this.config.hostId ?? this.config.allowedHosts[0]}`)) {
|
|
718
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
719
|
+
}
|
|
720
|
+
const token = `peer_${randomBytes(24).toString("base64url")}`;
|
|
721
|
+
await this.store.registerRoute({ alias: params.alias, binding: {
|
|
722
|
+
provider: "peer", hostId: this.config.hostId ?? this.config.allowedHosts[0],
|
|
723
|
+
routeHandle: peerHandle(process.getuid(), params.alias, token), registrationId: registrationId(),
|
|
724
|
+
}, registrationMode: "explicit_opt_in" });
|
|
725
|
+
const installed = (await this.store.inspectPrivateRoute(params.alias));
|
|
726
|
+
this.observeRoute(installed);
|
|
727
|
+
this.reconcileAdvertisement(installed);
|
|
728
|
+
return token;
|
|
729
|
+
}
|
|
730
|
+
async assertPeer(params) {
|
|
731
|
+
const route = await this.store.inspectPrivateRoute(params.alias);
|
|
732
|
+
const expected = Buffer.from(peerHandle(process.getuid(), params.alias, params.token));
|
|
733
|
+
const actualHandle = route?.binding.provider === "peer" && /^peer:[0-9a-f]{64}$/.test(route.binding.routeHandle)
|
|
734
|
+
? route.binding.routeHandle : `peer:${"0".repeat(64)}`;
|
|
735
|
+
const matches = timingSafeEqual(Buffer.from(actualHandle), expected);
|
|
736
|
+
if (!PEER_TOKEN.test(params.token) || route?.binding.provider !== "peer" || !matches) {
|
|
737
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
738
|
+
}
|
|
739
|
+
return route;
|
|
740
|
+
}
|
|
741
|
+
async unregisterPeer(params) {
|
|
742
|
+
const route = await this.assertPeer(params);
|
|
743
|
+
if (this.peerCleanupAliases.has(route.alias))
|
|
744
|
+
throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
|
|
745
|
+
this.peerCleanupAliases.add(route.alias);
|
|
746
|
+
try {
|
|
747
|
+
const result = await this.store.removeOwnedRouteAtomic({ alias: route.alias, binding: route.binding });
|
|
748
|
+
if (!result.removed)
|
|
749
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
750
|
+
await this.finishSettlements(result.settlements);
|
|
751
|
+
this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
|
|
752
|
+
this.forgetRoute(route);
|
|
753
|
+
await this.reconcileUnadvertisement(route);
|
|
754
|
+
}
|
|
755
|
+
finally {
|
|
756
|
+
this.peerCleanupAliases.delete(route.alias);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
async awaitPeer(params) {
|
|
760
|
+
this.assertWritable();
|
|
761
|
+
const route = await this.assertPeer(params);
|
|
762
|
+
const adapter = this.adapterFor(route.binding);
|
|
763
|
+
if (adapter?.awaitMessage === undefined)
|
|
764
|
+
throw new BridgeError("PROVIDER_UNAVAILABLE", "The provider is unavailable.", true);
|
|
765
|
+
const pending = adapter.awaitMessage({ alias: route.alias, ...route.binding });
|
|
766
|
+
this.kick(route.alias, route.binding.registrationId);
|
|
767
|
+
return await pending;
|
|
768
|
+
}
|
|
769
|
+
async peerReceipt(params) {
|
|
770
|
+
const route = await this.assertPeer(params);
|
|
771
|
+
const adapter = this.adapterFor(route.binding);
|
|
772
|
+
if (adapter?.acknowledgeReceipt({ alias: route.alias, ...route.binding, receipt: params.receipt }) === "rejected") {
|
|
773
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
774
|
+
}
|
|
775
|
+
}
|
|
562
776
|
async unregisterCodex(params) {
|
|
563
777
|
const route = await this.store.inspectPrivateRoute(params.alias);
|
|
564
778
|
if (route === undefined ||
|
|
@@ -570,7 +784,7 @@ export class GatewayService {
|
|
|
570
784
|
}
|
|
571
785
|
async removeCodexRegistration(alias) {
|
|
572
786
|
const route = await this.store.inspectPrivateRoute(alias);
|
|
573
|
-
if (route === undefined || route.binding.provider !== "codex") {
|
|
787
|
+
if (route === undefined || route.binding.provider !== "codex" || route.registrationMode === "federated_peer") {
|
|
574
788
|
throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The Codex registration is absent.");
|
|
575
789
|
}
|
|
576
790
|
await this.removeOwnedRoute(route, "CODEX_REGISTRATION_NOT_FOUND", false);
|
|
@@ -624,13 +838,16 @@ export class GatewayService {
|
|
|
624
838
|
}
|
|
625
839
|
async unselectClaude(params) {
|
|
626
840
|
const route = await this.resolveSelectedClaudeRoute(params.alias);
|
|
627
|
-
if (route === undefined)
|
|
841
|
+
if (route === undefined || route.registrationMode === "federated_peer")
|
|
628
842
|
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
|
|
629
843
|
await this.removeOwnedRoute(route, "CLAUDE_ROUTE_NOT_FOUND", true);
|
|
630
844
|
}
|
|
631
845
|
async pair(params) {
|
|
632
846
|
const endpoints = await this.resolvePairEndpoints(params, true);
|
|
633
847
|
const aliases = endpoints.aliases;
|
|
848
|
+
const ownerHost = aliases.map(aliasHost).sort()[0];
|
|
849
|
+
if ((this.config.hostId ?? this.config.allowedHosts[0]) !== ownerHost)
|
|
850
|
+
throw new ConsentOwnerError(ownerHost);
|
|
634
851
|
const attestation = pairParamThreadAttestation(params);
|
|
635
852
|
if (attestation !== undefined)
|
|
636
853
|
await this.assertThread(attestation.alias, attestation.threadId);
|
|
@@ -643,6 +860,9 @@ export class GatewayService {
|
|
|
643
860
|
async unpair(params) {
|
|
644
861
|
const endpoints = await this.resolvePairEndpoints(params, false);
|
|
645
862
|
const aliases = endpoints.aliases;
|
|
863
|
+
const ownerHost = aliases.map(aliasHost).sort()[0];
|
|
864
|
+
if ((this.config.hostId ?? this.config.allowedHosts[0]) !== ownerHost)
|
|
865
|
+
throw new ConsentOwnerError(ownerHost);
|
|
646
866
|
const attestation = pairParamThreadAttestation(params);
|
|
647
867
|
if (attestation !== undefined)
|
|
648
868
|
await this.assertThread(attestation.alias, attestation.threadId);
|
|
@@ -693,8 +913,11 @@ export class GatewayService {
|
|
|
693
913
|
async sendToClaude(params) {
|
|
694
914
|
try {
|
|
695
915
|
this.assertWritable();
|
|
696
|
-
const source =
|
|
697
|
-
|
|
916
|
+
const source = "peerToken" in params && params.peerToken !== undefined
|
|
917
|
+
? await this.assertPeer({ alias: params.fromAlias, token: params.peerToken })
|
|
918
|
+
: await this.assertThread(params.fromAlias, params.threadId);
|
|
919
|
+
const direct = await this.store.inspectPrivateRoute(params.toAlias);
|
|
920
|
+
const target = direct?.binding.provider === "peer" ? direct : await this.resolveSelectedClaudeRoute(params.toAlias);
|
|
698
921
|
if (target === undefined)
|
|
699
922
|
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The selected Claude route is absent.");
|
|
700
923
|
return await this.enqueueConversation({
|
|
@@ -710,29 +933,35 @@ export class GatewayService {
|
|
|
710
933
|
});
|
|
711
934
|
}
|
|
712
935
|
catch (error) {
|
|
713
|
-
return decisionFor(error);
|
|
936
|
+
return decisionFor(error, "peerToken" in params);
|
|
714
937
|
}
|
|
715
938
|
}
|
|
716
939
|
async sendToCodex(params) {
|
|
717
940
|
try {
|
|
718
941
|
this.assertWritable();
|
|
719
942
|
let source;
|
|
720
|
-
if (params.
|
|
943
|
+
if ("peerToken" in params && params.peerToken !== undefined) {
|
|
944
|
+
source = await this.assertPeer({ alias: params.fromAlias, token: params.peerToken });
|
|
945
|
+
}
|
|
946
|
+
else if (params.replyAddress === undefined) {
|
|
721
947
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
|
|
722
948
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
949
|
+
else {
|
|
950
|
+
const resolved = await this.claudeAdapter(aliasHost(params.fromAlias))?.resolveReplyAddress?.(params.replyAddress);
|
|
951
|
+
if (resolved === undefined)
|
|
952
|
+
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
|
|
953
|
+
source = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
954
|
+
route.alias === params.fromAlias && route.binding.routeHandle === resolved.routeHandle);
|
|
955
|
+
if (source === undefined)
|
|
956
|
+
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The reply route is not selected.");
|
|
957
|
+
}
|
|
732
958
|
const target = await this.store.inspectPrivateRoute(params.toAlias);
|
|
733
959
|
if (source === undefined || target === undefined) {
|
|
734
960
|
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The selected route is absent.");
|
|
735
961
|
}
|
|
962
|
+
if (target.binding.provider !== "codex" && target.binding.provider !== "peer") {
|
|
963
|
+
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The selected route is absent.");
|
|
964
|
+
}
|
|
736
965
|
return await this.enqueueConversation({
|
|
737
966
|
sourceAlias: params.fromAlias,
|
|
738
967
|
targetAlias: params.toAlias,
|
|
@@ -746,7 +975,7 @@ export class GatewayService {
|
|
|
746
975
|
});
|
|
747
976
|
}
|
|
748
977
|
catch (error) {
|
|
749
|
-
return decisionFor(error);
|
|
978
|
+
return decisionFor(error, "peerToken" in params);
|
|
750
979
|
}
|
|
751
980
|
}
|
|
752
981
|
async reply(params) {
|
|
@@ -801,7 +1030,7 @@ export class GatewayService {
|
|
|
801
1030
|
});
|
|
802
1031
|
}
|
|
803
1032
|
catch (error) {
|
|
804
|
-
return decisionFor(error);
|
|
1033
|
+
return decisionFor(error, params.caller.kind === "peer");
|
|
805
1034
|
}
|
|
806
1035
|
}
|
|
807
1036
|
async assertReplyCaller(conversation, caller) {
|
|
@@ -829,6 +1058,8 @@ export class GatewayService {
|
|
|
829
1058
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
|
|
830
1059
|
}
|
|
831
1060
|
}
|
|
1061
|
+
if (caller.kind === "peer")
|
|
1062
|
+
await this.assertPeer({ alias: caller.alias, token: caller.token });
|
|
832
1063
|
return route.binding;
|
|
833
1064
|
}
|
|
834
1065
|
async enqueueConversation(input) {
|
|
@@ -850,6 +1081,8 @@ export class GatewayService {
|
|
|
850
1081
|
: undefined;
|
|
851
1082
|
const sourceBinding = input.expectedSourceBinding ?? conversationSourceBinding ?? source?.binding;
|
|
852
1083
|
const targetBinding = input.expectedTargetBinding ?? conversationTargetBinding ?? target?.binding;
|
|
1084
|
+
const steer = input.text.startsWith("STEER:") && this.config.steeringEnabled &&
|
|
1085
|
+
sourceBinding?.provider === "claude" && targetBinding?.provider === "codex";
|
|
853
1086
|
if (input.existingConversation === undefined &&
|
|
854
1087
|
this.conversations.size >= MAX_CONVERSATIONS &&
|
|
855
1088
|
[...this.conversations.keys()].every((id) => this.conversationIsActive(id))) {
|
|
@@ -886,9 +1119,7 @@ export class GatewayService {
|
|
|
886
1119
|
expectedSourceRegistrationId: sourceBinding.registrationId,
|
|
887
1120
|
expectedTargetRegistrationId: targetBinding.registrationId,
|
|
888
1121
|
}),
|
|
889
|
-
...(
|
|
890
|
-
? { steer: true }
|
|
891
|
-
: {}),
|
|
1122
|
+
...(steer ? { steer: true } : {}),
|
|
892
1123
|
});
|
|
893
1124
|
}
|
|
894
1125
|
catch (error) {
|
|
@@ -909,9 +1140,8 @@ export class GatewayService {
|
|
|
909
1140
|
if (enqueued.supersededSettlement !== undefined) {
|
|
910
1141
|
await this.finishSettlement(enqueued.supersededSettlement);
|
|
911
1142
|
}
|
|
912
|
-
if (
|
|
1143
|
+
if (steer)
|
|
913
1144
|
this.kickSteer(input.targetAlias);
|
|
914
|
-
}
|
|
915
1145
|
this.kick(input.targetAlias);
|
|
916
1146
|
this.scheduleWake();
|
|
917
1147
|
return {
|
|
@@ -1020,8 +1250,9 @@ export class GatewayService {
|
|
|
1020
1250
|
else if (steer && this.running && !this.closing)
|
|
1021
1251
|
this.kickSteer(targetAlias);
|
|
1022
1252
|
else if (this.running)
|
|
1023
|
-
void this.store.inspectDispatchableTargets().then((targets) => {
|
|
1024
|
-
if (targets.includes(targetAlias)
|
|
1253
|
+
void Promise.all([this.store.inspectDispatchableTargets(), this.store.inspectPrivateRoute(targetAlias)]).then(([targets, route]) => {
|
|
1254
|
+
if (targets.includes(targetAlias) &&
|
|
1255
|
+
(route?.registrationMode !== "federated_peer" || this.peerClients.has(route.binding.hostId)))
|
|
1025
1256
|
this.kick(targetAlias);
|
|
1026
1257
|
}).catch(() => undefined);
|
|
1027
1258
|
});
|
|
@@ -1093,6 +1324,72 @@ export class GatewayService {
|
|
|
1093
1324
|
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "ROUTE_UNREGISTERED");
|
|
1094
1325
|
return false;
|
|
1095
1326
|
}
|
|
1327
|
+
if (target.registrationMode === "federated_peer") {
|
|
1328
|
+
const peer = this.peerClients.get(target.binding.hostId);
|
|
1329
|
+
if (peer === undefined || source === undefined || source.registrationMode === "federated_peer") {
|
|
1330
|
+
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
const sourceEndpoint = { alias: source.alias, provider: source.binding.provider,
|
|
1334
|
+
host: source.binding.hostId, routeRef: source.binding.registrationId };
|
|
1335
|
+
const targetEndpoint = { alias: target.alias, provider: target.binding.provider,
|
|
1336
|
+
host: target.binding.hostId, routeRef: target.binding.routeHandle };
|
|
1337
|
+
const params = {
|
|
1338
|
+
originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
|
|
1339
|
+
source: sourceEndpoint, target: targetEndpoint,
|
|
1340
|
+
edgeRef: peerEdgeRef([sourceEndpoint, targetEndpoint]),
|
|
1341
|
+
edgeOwnerHost: [sourceEndpoint.host, targetEndpoint.host].sort()[0],
|
|
1342
|
+
deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
|
|
1343
|
+
body: attempt.body,
|
|
1344
|
+
...(attempt.steer === true ? { steer: true } : {}),
|
|
1345
|
+
...(attempt.conversationIdSuffix === undefined ? {} : { conversationCorrelation: attempt.conversationIdSuffix }),
|
|
1346
|
+
};
|
|
1347
|
+
let armed = false, authorizationUncertain = false, acceptanceObserved = false, result;
|
|
1348
|
+
try {
|
|
1349
|
+
const prepared = peer.prepareHandoff(params);
|
|
1350
|
+
let authorized;
|
|
1351
|
+
try {
|
|
1352
|
+
authorized = await this.store.authorizeMessage({ messageId: attempt.messageId,
|
|
1353
|
+
attemptId: attempt.attemptId, sourceRegistrationId: attempt.sourceRegistrationId,
|
|
1354
|
+
targetRegistrationId: attempt.targetRegistrationId,
|
|
1355
|
+
prepared: { kind: "peer_handoff", bodyBytes: prepared.bodyBytes,
|
|
1356
|
+
bodySha256: prepared.bodySha256, frameBytes: prepared.frameBytes, sha256: prepared.sha256 } });
|
|
1357
|
+
}
|
|
1358
|
+
catch (error) {
|
|
1359
|
+
authorizationUncertain = true;
|
|
1360
|
+
throw error;
|
|
1361
|
+
}
|
|
1362
|
+
if (authorized.status !== "authorized") {
|
|
1363
|
+
prepared.cancel();
|
|
1364
|
+
if (authorized.status === "terminal")
|
|
1365
|
+
await this.finishSettlement(authorized.settlement);
|
|
1366
|
+
this.activeAttempts.delete(attempt.messageId);
|
|
1367
|
+
return false;
|
|
1368
|
+
}
|
|
1369
|
+
armed = true;
|
|
1370
|
+
await prepared.perform();
|
|
1371
|
+
acceptanceObserved = true;
|
|
1372
|
+
const accepted = await this.store.acceptMessage({ messageId: attempt.messageId,
|
|
1373
|
+
attemptId: attempt.attemptId, lossOutcome: "unconfirmed" });
|
|
1374
|
+
if (accepted.status !== "accepted")
|
|
1375
|
+
throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The peer acceptance fence is stale.");
|
|
1376
|
+
result = { state: "delivered", safeErrorCode: "PEER_HANDOFF_CONFIRMED" };
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
if (!armed && !authorizationUncertain) {
|
|
1380
|
+
peer.close();
|
|
1381
|
+
this.peerClients.delete(target.binding.hostId);
|
|
1382
|
+
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
|
|
1383
|
+
return false;
|
|
1384
|
+
}
|
|
1385
|
+
result = acceptanceObserved
|
|
1386
|
+
? { state: "unconfirmed", safeErrorCode: "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" }
|
|
1387
|
+
: { state: "ambiguous", safeErrorCode: authorizationUncertain ? "WRITE_AUTHORIZATION_UNCERTAIN" : "PEER_HANDOFF_OUTCOME_UNKNOWN" };
|
|
1388
|
+
}
|
|
1389
|
+
const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, attempt.sourceAlias, attempt.targetAlias);
|
|
1390
|
+
this.activeAttempts.delete(attempt.messageId);
|
|
1391
|
+
return requeued;
|
|
1392
|
+
}
|
|
1096
1393
|
const adapter = this.adapterFor(target.binding);
|
|
1097
1394
|
if (adapter === undefined) {
|
|
1098
1395
|
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "PROVIDER_UNAVAILABLE");
|
|
@@ -1177,7 +1474,7 @@ export class GatewayService {
|
|
|
1177
1474
|
}
|
|
1178
1475
|
},
|
|
1179
1476
|
onAccepted: async (evidence) => {
|
|
1180
|
-
if (target.binding.provider !== "codex" ||
|
|
1477
|
+
if ((target.binding.provider !== "codex" && target.binding.provider !== "peer") ||
|
|
1181
1478
|
evidence.attemptId !== attempt.attemptId) {
|
|
1182
1479
|
throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The provider accepted another attempt.");
|
|
1183
1480
|
}
|
|
@@ -1344,7 +1641,9 @@ export class GatewayService {
|
|
|
1344
1641
|
}
|
|
1345
1642
|
async onClaudeMessage(event) {
|
|
1346
1643
|
const target = await this.store.inspectPrivateRoute(event.targetAlias);
|
|
1347
|
-
|
|
1644
|
+
const localHost = this.config.hostId ?? this.config.allowedHosts[0];
|
|
1645
|
+
if (target === undefined || (target.binding.hostId !== event.endpoint.hostId &&
|
|
1646
|
+
!(target.registrationMode === "federated_peer" && event.endpoint.hostId === localHost))) {
|
|
1348
1647
|
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The native target is absent.");
|
|
1349
1648
|
}
|
|
1350
1649
|
const selected = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
@@ -1358,7 +1657,7 @@ export class GatewayService {
|
|
|
1358
1657
|
registrationId: `native_${bodyHash(`${event.endpoint.routeHandle}\0${event.sourceAlias}`).slice(0, 32)}`,
|
|
1359
1658
|
};
|
|
1360
1659
|
const conversationId = createGatewayConversationId();
|
|
1361
|
-
const steer = event.text.startsWith("STEER:") && this.config.steeringEnabled;
|
|
1660
|
+
const steer = target.binding.provider === "codex" && event.text.startsWith("STEER:") && this.config.steeringEnabled;
|
|
1362
1661
|
this.assertWritable();
|
|
1363
1662
|
const enqueued = await this.store.enqueueNativeIngress({
|
|
1364
1663
|
source: { alias: event.sourceAlias, binding: sourceBinding },
|
|
@@ -1820,6 +2119,9 @@ export class GatewayService {
|
|
|
1820
2119
|
const due = [
|
|
1821
2120
|
this.nextDiscoveryAt || now + DISCOVERY_INTERVAL_MS,
|
|
1822
2121
|
this.nextDoctorAt || now + CODEX_DOCTOR_INTERVAL_MS,
|
|
2122
|
+
...((this.config.peerNodes?.length ?? 0) > 0
|
|
2123
|
+
? [this.nextPeerRefreshAt || now + PEER_REFRESH_INTERVAL_MS]
|
|
2124
|
+
: []),
|
|
1823
2125
|
...(deadlineAt === undefined ? [] : [Date.parse(deadlineAt)]),
|
|
1824
2126
|
...[...this.pendingClaudeReplies.values()].flatMap((rows) => rows.map((row) => Date.parse(row.deadlineAt))),
|
|
1825
2127
|
...[...this.progressWatches.values()].map((watch) => Date.parse(watch.nextActionAt)),
|
|
@@ -1846,11 +2148,100 @@ export class GatewayService {
|
|
|
1846
2148
|
await this.refreshCodexDoctor().catch(() => undefined);
|
|
1847
2149
|
this.nextDoctorAt = now.getTime() + CODEX_DOCTOR_INTERVAL_MS;
|
|
1848
2150
|
}
|
|
2151
|
+
if (this.nextPeerRefreshAt > 0 && now.getTime() >= this.nextPeerRefreshAt) {
|
|
2152
|
+
const operation = this.refreshPeers();
|
|
2153
|
+
this.peerRefreshOperations.add(operation);
|
|
2154
|
+
try {
|
|
2155
|
+
await operation;
|
|
2156
|
+
}
|
|
2157
|
+
finally {
|
|
2158
|
+
this.peerRefreshOperations.delete(operation);
|
|
2159
|
+
}
|
|
2160
|
+
this.nextPeerRefreshAt = now.getTime() + PEER_REFRESH_INTERVAL_MS;
|
|
2161
|
+
}
|
|
2162
|
+
if (!this.running || this.closing)
|
|
2163
|
+
return;
|
|
1849
2164
|
for (const target of await this.store.inspectDispatchableTargets())
|
|
1850
2165
|
this.kick(target);
|
|
1851
2166
|
await this.publish();
|
|
1852
2167
|
this.scheduleWake();
|
|
1853
2168
|
}
|
|
2169
|
+
async refreshPeers() {
|
|
2170
|
+
const localHost = this.config.hostId ?? this.config.allowedHosts[0];
|
|
2171
|
+
await Promise.all((this.config.peerNodes ?? []).map(async (peerHost) => {
|
|
2172
|
+
let failureCode = "PEER_TUNNEL_UNAVAILABLE";
|
|
2173
|
+
try {
|
|
2174
|
+
let client = this.peerClients.get(peerHost);
|
|
2175
|
+
if (client === undefined) {
|
|
2176
|
+
failureCode = "PEER_DIAL_FAILED";
|
|
2177
|
+
client = await this.spawnPeer({ node: peerHost, localHost });
|
|
2178
|
+
if (!this.running || this.closing) {
|
|
2179
|
+
client.close();
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
this.peerClients.set(peerHost, client);
|
|
2183
|
+
}
|
|
2184
|
+
failureCode = "PEER_TUNNEL_UNAVAILABLE";
|
|
2185
|
+
const catalog = await client.catalog();
|
|
2186
|
+
if (!this.running || this.closing)
|
|
2187
|
+
return;
|
|
2188
|
+
this.recordPeerFailure(peerHost);
|
|
2189
|
+
const prior = this.peerCatalogs.get(peerHost);
|
|
2190
|
+
let routes = await this.store.inspectPrivateRoutes().then((rows) => rows.filter((route) => route.registrationMode === "federated_peer" && route.binding.hostId === peerHost));
|
|
2191
|
+
if (!catalog.complete || catalog.truncated) {
|
|
2192
|
+
const observedAt = this.now().toISOString();
|
|
2193
|
+
for (const route of routes)
|
|
2194
|
+
this.peerRouteViews.set(route.alias, { route: route.binding,
|
|
2195
|
+
state: "unobserved", observedAt, safeErrorCode: "PEER_CATALOG_INCOMPLETE" });
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
const authorityChanged = peerCatalogAuthorityChanged(prior, catalog);
|
|
2199
|
+
const viewChanged = peerCatalogViewChanged(prior, catalog);
|
|
2200
|
+
if (authorityChanged) {
|
|
2201
|
+
const before = routes;
|
|
2202
|
+
const reconciled = await this.store.reconcilePeerCatalog(peerHost, catalog);
|
|
2203
|
+
routes = [...reconciled.routes];
|
|
2204
|
+
await this.finishSettlements(reconciled.settlements);
|
|
2205
|
+
for (const old of before)
|
|
2206
|
+
if (!routes.some((route) => route.alias === old.alias &&
|
|
2207
|
+
route.binding.registrationId === old.binding.registrationId)) {
|
|
2208
|
+
this.peerRouteViews.delete(old.alias);
|
|
2209
|
+
await this.reconcileUnadvertisement(old);
|
|
2210
|
+
}
|
|
2211
|
+
for (const route of routes)
|
|
2212
|
+
if (!before.some((old) => old.alias === route.alias &&
|
|
2213
|
+
old.binding.registrationId === route.binding.registrationId))
|
|
2214
|
+
this.reconcileAdvertisement(route);
|
|
2215
|
+
}
|
|
2216
|
+
this.peerCatalogs.set(peerHost, catalog);
|
|
2217
|
+
const observedAt = this.now().toISOString();
|
|
2218
|
+
for (const route of routes) {
|
|
2219
|
+
const row = catalog.routes.find((candidate) => candidate.ref === route.binding.routeHandle);
|
|
2220
|
+
const state = row?.state === "idle" || row?.state === "busy" || row?.state === "awaiting_approval"
|
|
2221
|
+
? row.state : "unobserved";
|
|
2222
|
+
this.peerRouteViews.set(route.alias, { route: route.binding, state, observedAt,
|
|
2223
|
+
...(state === "unobserved" ? { safeErrorCode: row?.safeErrorCode ?? "PEER_ROUTE_STALE" } : {}) });
|
|
2224
|
+
if (row?.enabled === true)
|
|
2225
|
+
this.kick(route.alias, route.binding.registrationId);
|
|
2226
|
+
}
|
|
2227
|
+
if (viewChanged)
|
|
2228
|
+
this.revision += 1;
|
|
2229
|
+
}
|
|
2230
|
+
catch {
|
|
2231
|
+
this.peerClients.get(peerHost)?.close();
|
|
2232
|
+
this.peerClients.delete(peerHost);
|
|
2233
|
+
if (!this.running || this.closing)
|
|
2234
|
+
return;
|
|
2235
|
+
this.recordPeerFailure(peerHost, failureCode);
|
|
2236
|
+
const observedAt = this.now().toISOString();
|
|
2237
|
+
for (const route of await this.store.inspectPrivateRoutes())
|
|
2238
|
+
if (route.registrationMode === "federated_peer" &&
|
|
2239
|
+
route.binding.hostId === peerHost)
|
|
2240
|
+
this.peerRouteViews.set(route.alias, { route: route.binding,
|
|
2241
|
+
state: "unobserved", observedAt, safeErrorCode: "PEER_TUNNEL_UNAVAILABLE" });
|
|
2242
|
+
}
|
|
2243
|
+
}));
|
|
2244
|
+
}
|
|
1854
2245
|
async refreshClaudeDiscovery() {
|
|
1855
2246
|
const seen = new Set();
|
|
1856
2247
|
for (const adapter of this.adapters) {
|
|
@@ -1921,5 +2312,14 @@ export class GatewayService {
|
|
|
1921
2312
|
while (this.runtimeAlerts.length > gatewayPublicSnapshotLimits.alerts)
|
|
1922
2313
|
this.runtimeAlerts.shift();
|
|
1923
2314
|
}
|
|
2315
|
+
recordPeerFailure(host, code) {
|
|
2316
|
+
for (let index = this.runtimeAlerts.length - 1; index >= 0; index -= 1) {
|
|
2317
|
+
const alert = this.runtimeAlerts[index];
|
|
2318
|
+
if (alert?.host === host && (alert.code === "PEER_DIAL_FAILED" || alert.code === "PEER_TUNNEL_UNAVAILABLE"))
|
|
2319
|
+
this.runtimeAlerts.splice(index, 1);
|
|
2320
|
+
}
|
|
2321
|
+
if (code !== undefined)
|
|
2322
|
+
this.runtimeAlerts.push({ code, severity: "warning", timestamp: this.now().toISOString(), host });
|
|
2323
|
+
}
|
|
1924
2324
|
}
|
|
1925
2325
|
//# sourceMappingURL=service.js.map
|