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
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { BridgeError } from "../errors.js";
|
|
3
|
-
import { createGatewayConversationId, startGatewayControlServer, } from "./control.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { peerEdgeRef, peerRouteRef } from "./peer-protocol.js";
|
|
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";
|
|
3
|
+
import { createGatewayConversationId, startGatewayControlServer, GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS, } from "./control.js";
|
|
4
|
+
import { PeerConnectionLostError, PeerRequestError, spawnPeerClient } from "./peer-client.js";
|
|
5
|
+
import { isPeerHandoffRefusal, peerRouteRef } from "./peer-protocol.js";
|
|
8
6
|
import { GatewayStore } from "./store.js";
|
|
9
|
-
import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits,
|
|
7
|
+
import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, gatewayHealthWord, directionId, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, parseDirection, projectGatewayPublicSnapshot, } from "./types.js";
|
|
10
8
|
const SAFE_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
11
9
|
const PRIVATE_HANDLE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
12
10
|
const PUBLIC_ALIAS = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
|
|
13
11
|
const PEER_TOKEN = /^peer_[A-Za-z0-9_-]{32}$/;
|
|
14
12
|
const DISCOVERY_INTERVAL_MS = 30_000;
|
|
15
|
-
const
|
|
13
|
+
const MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS = 30_000;
|
|
16
14
|
const PEER_REFRESH_INTERVAL_MS = 30_000;
|
|
15
|
+
/** A failed timed retirement of an ephemeral peer is retried this much later… */
|
|
16
|
+
const EPHEMERAL_EXPIRY_RETRY_MS = 5_000;
|
|
17
|
+
/** …this many times in a row before the registration is left to the next restart. */
|
|
18
|
+
const EPHEMERAL_EXPIRY_MAX_ATTEMPTS = 3;
|
|
19
|
+
const PEER_FAILURE_CODES = ["PEER_DIAL_FAILED", "PEER_TUNNEL_UNAVAILABLE", "PEER_PROTOCOL_MISMATCH"];
|
|
17
20
|
const CLEAN_RETRY_DELAY_MS = 500;
|
|
18
21
|
const MAX_CONVERSATIONS = 1_024;
|
|
19
22
|
const MAX_PENDING_CLAUDE_REPLIES = MAX_CONVERSATIONS;
|
|
@@ -39,16 +42,7 @@ function aliasHost(alias) {
|
|
|
39
42
|
function safeCode(value, fallback) {
|
|
40
43
|
return value !== undefined && SAFE_CODE.test(value) ? value : fallback;
|
|
41
44
|
}
|
|
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
45
|
function decisionFor(error, peerPrincipal = false) {
|
|
50
|
-
if (error instanceof ConsentOwnerError)
|
|
51
|
-
return { accepted: false, code: "conflict", ownerHost: error.ownerHost };
|
|
52
46
|
if (!(error instanceof BridgeError))
|
|
53
47
|
return { accepted: false, code: "rejected" };
|
|
54
48
|
if (error.code.includes("NOT_FOUND") || error.code.includes("NOT_AVAILABLE")) {
|
|
@@ -63,11 +57,42 @@ function decisionFor(error, peerPrincipal = false) {
|
|
|
63
57
|
if (error.code.includes("UNAVAILABLE") || error.code.includes("OFFLINE") || error.code.includes("UNOBSERVED")) {
|
|
64
58
|
return { accepted: false, code: "unavailable" };
|
|
65
59
|
}
|
|
66
|
-
if ((peerPrincipal && error.code === "ROUTE_UNREGISTERED") || error.code.includes("MISMATCH") ||
|
|
60
|
+
if ((peerPrincipal && error.code === "ROUTE_UNREGISTERED") || error.code.includes("MISMATCH") ||
|
|
61
|
+
error.code.includes("CHANGED") || error.code.includes("ADDRESS")) {
|
|
67
62
|
return { accepted: false, code: "route_mismatch" };
|
|
68
63
|
}
|
|
69
64
|
return { accepted: false, code: "rejected" };
|
|
70
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* The refusal a send returns: the coarse decision clients branch on,
|
|
68
|
+
* plus the safe code behind it so the CLI can name the one remedy that fits.
|
|
69
|
+
* Only a BridgeError's own code is carried; anything else stays anonymous.
|
|
70
|
+
*/
|
|
71
|
+
function sendRefusal(error, peerPrincipal = false) {
|
|
72
|
+
const decision = decisionFor(error, peerPrincipal);
|
|
73
|
+
const reason = error instanceof BridgeError && SAFE_CODE.test(error.code) ? error.code : undefined;
|
|
74
|
+
return { accepted: false, code: decision.code, ...(reason === undefined ? {} : { reason }) };
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A grammar-valid alias for a session that cannot have the name it discovered
|
|
78
|
+
* under, because a live session already holds it. The suffix is derived from
|
|
79
|
+
* the session's own route handle, so it is stable across sends, and it is a
|
|
80
|
+
* hash rather than the handle itself: a native session UUID must never enter a
|
|
81
|
+
* public alias.
|
|
82
|
+
*/
|
|
83
|
+
function disambiguatedAlias(alias, routeHandle) {
|
|
84
|
+
const at = alias.lastIndexOf("@");
|
|
85
|
+
const name = alias.slice(0, at), host = alias.slice(at + 1);
|
|
86
|
+
const suffix = createHash("sha256").update(routeHandle).digest("hex").slice(0, 8);
|
|
87
|
+
return `${name.slice(0, 32 - suffix.length - 1)}-${suffix}@${host}`;
|
|
88
|
+
}
|
|
89
|
+
/** The decoder already accepted exactly one credential, so the last arm holds. */
|
|
90
|
+
function replyCaller(params) {
|
|
91
|
+
const { fromAlias, threadId, replyAddress, peerToken } = params;
|
|
92
|
+
return threadId !== undefined ? { kind: "codex", alias: fromAlias, threadId }
|
|
93
|
+
: replyAddress !== undefined ? { kind: "claude", alias: fromAlias, replyAddress }
|
|
94
|
+
: { kind: "peer", alias: fromAlias, token: peerToken };
|
|
95
|
+
}
|
|
71
96
|
function sameBinding(left, right) {
|
|
72
97
|
return left.provider === right.provider && left.hostId === right.hostId &&
|
|
73
98
|
left.routeHandle === right.routeHandle && left.registrationId === right.registrationId;
|
|
@@ -96,14 +121,8 @@ function conversationIdForSuffix(suffix) {
|
|
|
96
121
|
const fresh = createGatewayConversationId();
|
|
97
122
|
return suffix === undefined ? fresh : `${fresh.slice(0, -8)}${suffix}`;
|
|
98
123
|
}
|
|
99
|
-
function bodyHash(body) {
|
|
100
|
-
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
101
|
-
}
|
|
102
124
|
function peerCatalogAuthorityChanged(prior, current) {
|
|
103
|
-
const authority = (catalog) => JSON.stringify(
|
|
104
|
-
catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
105
|
-
[...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
106
|
-
]);
|
|
125
|
+
const authority = (catalog) => JSON.stringify(catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)));
|
|
107
126
|
return prior === undefined || authority(prior) !== authority(current);
|
|
108
127
|
}
|
|
109
128
|
function peerCatalogViewChanged(prior, current) {
|
|
@@ -111,7 +130,6 @@ function peerCatalogViewChanged(prior, current) {
|
|
|
111
130
|
catalog.health,
|
|
112
131
|
catalog.connectors.map(({ lastSeenAt: _seen, observationAgeMs: _age, ...row }) => row),
|
|
113
132
|
catalog.routes.map(({ lastSeenAt: _seen, ...row }) => row).sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
114
|
-
[...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
115
133
|
catalog.alerts.map(({ timestamp: _timestamp, ...row }) => row),
|
|
116
134
|
]);
|
|
117
135
|
return prior === undefined || view(prior) !== view(current);
|
|
@@ -123,15 +141,14 @@ export class GatewayService {
|
|
|
123
141
|
config;
|
|
124
142
|
store;
|
|
125
143
|
adapters;
|
|
126
|
-
publishDashboard;
|
|
127
144
|
now;
|
|
128
145
|
timers;
|
|
129
146
|
nativePeerCwd;
|
|
130
|
-
|
|
147
|
+
managedCodexSocketHeld;
|
|
131
148
|
spawnPeer;
|
|
132
149
|
peerClients = new Map();
|
|
133
150
|
peerCatalogs = new Map();
|
|
134
|
-
|
|
151
|
+
routeCleanupAliases = new Set();
|
|
135
152
|
/** View-only freshness; never consulted by routing or write authorization. */
|
|
136
153
|
peerRouteViews = new Map();
|
|
137
154
|
connectors = new Map();
|
|
@@ -146,18 +163,25 @@ export class GatewayService {
|
|
|
146
163
|
peerRefreshOperations = new Set();
|
|
147
164
|
pendingClaudeReplies = new Map();
|
|
148
165
|
nativeReceipts = new Map();
|
|
149
|
-
progressWatches = new Map();
|
|
150
|
-
progressWatchEvents = [];
|
|
151
166
|
dispatchRunners = new Map();
|
|
152
167
|
steerRunners = new Map();
|
|
153
168
|
startingTargets = new Set();
|
|
154
169
|
runtimeAlerts = [];
|
|
170
|
+
/**
|
|
171
|
+
* Ephemeral peer registrations and the wall-clock instant each one dies.
|
|
172
|
+
* Nothing durable records them, so this map is the whole lifetime: it is
|
|
173
|
+
* swept on the ordinary maintenance tick, and a restart simply never sees
|
|
174
|
+
* the routes at all.
|
|
175
|
+
*/
|
|
176
|
+
ephemeralPeerExpiries = new Map();
|
|
177
|
+
/** Consecutive failed retirements per ephemeral alias; cleared on success or removal. */
|
|
178
|
+
ephemeralExpiryFailures = new Map();
|
|
155
179
|
control;
|
|
156
180
|
wakeTimer;
|
|
157
181
|
nextDiscoveryAt = 0;
|
|
158
|
-
|
|
182
|
+
nextManagedCodexCheckAt = 0;
|
|
159
183
|
nextPeerRefreshAt = 0;
|
|
160
|
-
|
|
184
|
+
managedCodexSocketHeldOutside = false;
|
|
161
185
|
revision = 0;
|
|
162
186
|
snapshotRevision = 0;
|
|
163
187
|
snapshotFingerprint;
|
|
@@ -168,10 +192,9 @@ export class GatewayService {
|
|
|
168
192
|
this.config = options.config;
|
|
169
193
|
this.adapters = [...(options.adapters ?? [])];
|
|
170
194
|
this.store = options.store ?? new GatewayStore(options.config, options.now === undefined ? {} : { now: options.now });
|
|
171
|
-
this.publishDashboard = options.publishDashboard ?? publishGatewayDashboard;
|
|
172
195
|
this.now = options.now ?? (() => new Date());
|
|
173
196
|
this.nativePeerCwd = options.nativePeerCwd ?? process.cwd();
|
|
174
|
-
this.
|
|
197
|
+
this.managedCodexSocketHeld = options.managedCodexSocketHeld;
|
|
175
198
|
this.spawnPeer = options.spawnPeer ?? spawnPeerClient;
|
|
176
199
|
this.timers = options.timers ?? {
|
|
177
200
|
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
@@ -211,12 +234,9 @@ export class GatewayService {
|
|
|
211
234
|
if (registry !== undefined)
|
|
212
235
|
runtime.registry = publicRegistry(registry, false);
|
|
213
236
|
this.connectors.set(key, runtime);
|
|
214
|
-
if (started.ownedRoute !== undefined) {
|
|
215
|
-
await this.installOwnedRoute(adapter, started.ownedRoute);
|
|
216
|
-
}
|
|
217
237
|
}
|
|
218
238
|
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
219
|
-
await this.
|
|
239
|
+
await this.refreshManagedCodexSocket().catch(() => undefined);
|
|
220
240
|
const control = await startGatewayControlServer({
|
|
221
241
|
stateDir: this.store.rootDir,
|
|
222
242
|
socketPath: this.config.controlSocketPath,
|
|
@@ -235,11 +255,10 @@ export class GatewayService {
|
|
|
235
255
|
await this.observeLoadedRoutes();
|
|
236
256
|
const now = this.now().getTime();
|
|
237
257
|
this.nextDiscoveryAt = now + DISCOVERY_INTERVAL_MS;
|
|
238
|
-
this.
|
|
258
|
+
this.nextManagedCodexCheckAt = now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
|
|
239
259
|
this.nextPeerRefreshAt = this.config.peerNodes.length > 0 ? now : 0;
|
|
240
260
|
for (const target of await this.store.inspectDispatchableTargets())
|
|
241
261
|
this.kick(target);
|
|
242
|
-
await this.publish();
|
|
243
262
|
this.scheduleWake();
|
|
244
263
|
assertActive();
|
|
245
264
|
}
|
|
@@ -310,9 +329,10 @@ export class GatewayService {
|
|
|
310
329
|
const decide = async (operation, peerPrincipal = false) => {
|
|
311
330
|
try {
|
|
312
331
|
this.assertWritable();
|
|
313
|
-
await operation();
|
|
332
|
+
const decision = await operation();
|
|
333
|
+
if (decision !== undefined)
|
|
334
|
+
return decision;
|
|
314
335
|
this.revision += 1;
|
|
315
|
-
await this.publish();
|
|
316
336
|
return { accepted: true, code: "ok" };
|
|
317
337
|
}
|
|
318
338
|
catch (error) {
|
|
@@ -327,7 +347,6 @@ export class GatewayService {
|
|
|
327
347
|
this.assertWritable();
|
|
328
348
|
const token = await this.registerPeer(params);
|
|
329
349
|
this.revision += 1;
|
|
330
|
-
await this.publish();
|
|
331
350
|
return token === undefined ? { accepted: true, code: "ok" } : { accepted: true, code: "ok", token };
|
|
332
351
|
}
|
|
333
352
|
catch (error) {
|
|
@@ -337,25 +356,33 @@ export class GatewayService {
|
|
|
337
356
|
unregisterPeer: (params) => decide(async () => this.unregisterPeer(params), true),
|
|
338
357
|
awaitPeer: (params) => this.awaitPeer(params),
|
|
339
358
|
peerReceipt: (params) => decide(async () => this.peerReceipt(params), true),
|
|
359
|
+
retireRoute: async ({ alias }) => {
|
|
360
|
+
try {
|
|
361
|
+
return await this.retireRoute(alias);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
return sendRefusal(error);
|
|
365
|
+
}
|
|
366
|
+
},
|
|
340
367
|
unregisterCodex: (params) => decide(async () => this.unregisterCodex(params)),
|
|
341
|
-
removeCodexRegistration: (params) => decide(async () => this.removeCodexRegistration(params.alias)),
|
|
342
|
-
selectClaude: (params) => decide(async () => this.selectClaude(params)),
|
|
343
|
-
unselectClaude: (params) => decide(async () => this.unselectClaude(params)),
|
|
344
|
-
pair: (params) => decide(async () => this.pair(params)),
|
|
345
|
-
unpair: (params) => decide(async () => this.unpair(params)),
|
|
346
368
|
listSnapshot: () => this.snapshot(),
|
|
347
369
|
observeSnapshot: () => this.observeSnapshot(),
|
|
348
370
|
deliveryStatus: (params) => this.deliveryStatus(params.token),
|
|
349
|
-
untrack: (params) => decide(async () => this.untrack(params.conversationId)),
|
|
350
371
|
send: (params) => this.send(params),
|
|
351
|
-
|
|
352
|
-
refreshDashboard: async () => {
|
|
372
|
+
refreshDiscovery: async () => {
|
|
353
373
|
if (this.closing) {
|
|
354
374
|
return { accepted: false, code: "unavailable", revision: this.revision };
|
|
355
375
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
376
|
+
try {
|
|
377
|
+
// An operator-triggered rescan reports its own failure. It must never
|
|
378
|
+
// journal `discovery_refreshed` for a scan that did not complete, and
|
|
379
|
+
// must never answer `accepted: true` for a provider that is down.
|
|
380
|
+
await this.refreshClaudeDiscovery();
|
|
381
|
+
await this.recordActivity("discovery", "discovery_refreshed", [], true);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
return { accepted: false, code: decisionFor(error).code, revision: this.revision };
|
|
385
|
+
}
|
|
359
386
|
return { accepted: true, code: "ok", revision: this.revision };
|
|
360
387
|
},
|
|
361
388
|
peerCatalog: ({ peerHost }) => this.buildPeerCatalog(peerHost),
|
|
@@ -366,12 +393,15 @@ export class GatewayService {
|
|
|
366
393
|
const localHost = this.config.hostId;
|
|
367
394
|
if (!this.config.peerNodes.includes(peerHost))
|
|
368
395
|
throw new BridgeError("PEER_NOT_CONFIGURED", "The requested peer host is not configured.");
|
|
369
|
-
const [snapshot, privateRoutes
|
|
370
|
-
this.snapshot(), this.store.inspectPrivateRoutes(),
|
|
396
|
+
const [snapshot, privateRoutes] = await Promise.all([
|
|
397
|
+
this.snapshot(), this.store.inspectPrivateRoutes(),
|
|
371
398
|
]);
|
|
372
|
-
|
|
399
|
+
// An ephemeral registration is never published: a peer node that mirrored
|
|
400
|
+
// one would hold a route this broker will delete on a timer and can never
|
|
401
|
+
// restore, and a self-test must not leave a trace on another machine.
|
|
402
|
+
const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" &&
|
|
403
|
+
route.binding.hostId === localHost && !this.store.isEphemeralRegistration(route.binding.registrationId));
|
|
373
404
|
const publicRoutes = new Map(snapshot.routes.map((route) => [route.alias, route]));
|
|
374
|
-
const routeByAlias = new Map(privateRoutes.map((route) => [route.alias, route]));
|
|
375
405
|
const routes = localRoutes.map((route) => {
|
|
376
406
|
const row = publicRoutes.get(route.alias);
|
|
377
407
|
return {
|
|
@@ -381,32 +411,28 @@ export class GatewayService {
|
|
|
381
411
|
...(row?.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
|
|
382
412
|
};
|
|
383
413
|
});
|
|
384
|
-
const consentEdges = privateEdges.flatMap((edge) => {
|
|
385
|
-
if (edge.endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0] !== localHost)
|
|
386
|
-
return [];
|
|
387
|
-
const rows = edge.endpoints.map((endpoint) => routeByAlias.get(endpoint.alias));
|
|
388
|
-
if (rows[0] === undefined || rows[1] === undefined ||
|
|
389
|
-
!rows.some((route) => route?.binding.hostId === peerHost) ||
|
|
390
|
-
!rows.some((route) => route?.binding.hostId === localHost))
|
|
391
|
-
return [];
|
|
392
|
-
const endpoint = (route) => ({ alias: route.alias, provider: route.binding.provider,
|
|
393
|
-
host: route.binding.hostId, routeRef: route.registrationMode === "federated_peer"
|
|
394
|
-
? route.binding.routeHandle : peerRouteRef(localHost, route.binding.registrationId) });
|
|
395
|
-
const endpoints = [endpoint(rows[0]), endpoint(rows[1])];
|
|
396
|
-
return [{ ref: peerEdgeRef(endpoints), ownerHost: localHost, endpoints }];
|
|
397
|
-
});
|
|
398
414
|
return { revision: this.revision, complete: true, truncated: false, generatedAt: snapshot.generatedAt,
|
|
399
415
|
health: snapshot.health, connectors: snapshot.connectors.filter((row) => row.host === localHost).map((row) => ({
|
|
400
416
|
provider: row.provider, host: row.host, health: row.health, protocol: row.protocol, protocolVersion: row.protocolVersion,
|
|
401
417
|
...(row.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
|
|
402
418
|
...(row.observationAgeMs === undefined ? {} : { observationAgeMs: row.observationAgeMs }),
|
|
403
419
|
...(row.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
|
|
404
|
-
})), routes,
|
|
420
|
+
})), routes, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
|
|
405
421
|
}
|
|
406
422
|
async receivePeerHandoff(peerHost, handoff) {
|
|
407
423
|
if (!this.config.peerNodes.includes(peerHost))
|
|
408
424
|
throw new BridgeError("PEER_NOT_CONFIGURED", "The sending peer host is not configured.");
|
|
409
|
-
|
|
425
|
+
let enqueued;
|
|
426
|
+
try {
|
|
427
|
+
enqueued = await this.store.enqueuePeerHandoff(peerHost, handoff);
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
const refusal = { accepted: false, reason: error instanceof BridgeError ? error.code : undefined };
|
|
431
|
+
if (isPeerHandoffRefusal(refusal))
|
|
432
|
+
return refusal;
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
// Post-admission bookkeeping is deliberately outside the refusal catch.
|
|
410
436
|
if (enqueued.messageId !== undefined) {
|
|
411
437
|
const source = await this.store.inspectPrivateRoute(handoff.source.alias);
|
|
412
438
|
const target = await this.store.inspectPrivateRoute(handoff.target.alias);
|
|
@@ -415,12 +441,11 @@ export class GatewayService {
|
|
|
415
441
|
const conversationId = conversationIdForSuffix(handoff.conversationCorrelation);
|
|
416
442
|
this.rememberConversation({ id: conversationId, sourceAlias: source.alias, targetAlias: target.alias,
|
|
417
443
|
sourceBinding: source.binding, targetBinding: target.binding, expectsReply: handoff.expectsReply,
|
|
418
|
-
|
|
444
|
+
nextSequence: 1 });
|
|
419
445
|
this.messageContexts.set(enqueued.messageId, { conversationId, expectsReply: handoff.expectsReply });
|
|
420
446
|
this.kick(target.alias, target.binding.registrationId);
|
|
421
447
|
}
|
|
422
448
|
this.revision += 1;
|
|
423
|
-
await this.publish();
|
|
424
449
|
return { accepted: true };
|
|
425
450
|
}
|
|
426
451
|
async snapshot() {
|
|
@@ -449,34 +474,8 @@ export class GatewayService {
|
|
|
449
474
|
...(persisted.safeErrorCode === undefined ? {} : { safeErrorCode: persisted.safeErrorCode }),
|
|
450
475
|
};
|
|
451
476
|
});
|
|
452
|
-
const connectors =
|
|
453
|
-
|
|
454
|
-
const age = observedAt === undefined ? undefined : Math.max(0, now.getTime() - Date.parse(observedAt));
|
|
455
|
-
const stale = runtime.health === "healthy" &&
|
|
456
|
-
(age === undefined || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS);
|
|
457
|
-
const doctor = runtime.adapter.identity.provider === "codex" ? this.codexDoctorResult : undefined;
|
|
458
|
-
const doctorConditions = doctor === undefined
|
|
459
|
-
? [] : [...new Set(doctor.conditions)];
|
|
460
|
-
if (doctor !== undefined && stale && !doctorConditions.includes("observation_stale"))
|
|
461
|
-
doctorConditions.push("observation_stale");
|
|
462
|
-
const managedLayoutMissing = doctorConditions.includes("managed_layout_missing");
|
|
463
|
-
const connectorCode = stale ? "CONNECTOR_OBSERVATION_STALE" : runtime.safeErrorCode ??
|
|
464
|
-
(managedLayoutMissing ? "MANAGED_CODEX_UNAVAILABLE" : undefined);
|
|
465
|
-
return {
|
|
466
|
-
provider: runtime.adapter.identity.provider,
|
|
467
|
-
host: runtime.adapter.identity.hostId,
|
|
468
|
-
health: stale || managedLayoutMissing ? "degraded" : runtime.health,
|
|
469
|
-
protocol: runtime.adapter.protocol,
|
|
470
|
-
protocolVersion: runtime.adapter.protocolVersion,
|
|
471
|
-
...(observedAt === undefined ? {} : { lastSeenAt: observedAt }),
|
|
472
|
-
...(age === undefined ? {} : { observationAgeMs: age }),
|
|
473
|
-
...(doctorConditions.length === 0 ? {} : {
|
|
474
|
-
codexDoctor: { conditions: doctorConditions.slice(0, 2) },
|
|
475
|
-
}),
|
|
476
|
-
...(runtime.registry === undefined ? {} : { registry: runtime.registry }),
|
|
477
|
-
...(connectorCode === undefined ? {} : { safeErrorCode: connectorCode }),
|
|
478
|
-
};
|
|
479
|
-
});
|
|
477
|
+
const connectors = this.publicConnectors(now);
|
|
478
|
+
const word = gatewayHealthWord(connectors);
|
|
480
479
|
const availablePeers = [...this.candidates.values()]
|
|
481
480
|
.filter((candidate) => !this.collidingClaudeAliases.has(candidate.alias))
|
|
482
481
|
.slice(0, gatewayPublicSnapshotLimits.availablePeers)
|
|
@@ -486,39 +485,18 @@ export class GatewayService {
|
|
|
486
485
|
host: candidate.adapter.identity.hostId,
|
|
487
486
|
state: candidate.state,
|
|
488
487
|
validated: true,
|
|
489
|
-
|
|
488
|
+
routed: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
|
|
490
489
|
lastSeenAt: candidate.observedAt,
|
|
491
490
|
}));
|
|
492
|
-
const progressWatches = [...this.progressWatches.values()]
|
|
493
|
-
.slice(0, gatewayPublicSnapshotLimits.progressWatches)
|
|
494
|
-
.map((watch) => ({
|
|
495
|
-
conversationIdSuffix: watch.conversationId.slice(-8),
|
|
496
|
-
ownerAlias: watch.ownerAlias,
|
|
497
|
-
workerAlias: watch.workerAlias,
|
|
498
|
-
lastActivityAt: watch.lastActivityAt,
|
|
499
|
-
nextActionAt: watch.nextActionAt,
|
|
500
|
-
nudgeCount: watch.nudgeCount,
|
|
501
|
-
}));
|
|
502
|
-
const progressWatchEvents = this.progressWatchEvents.slice(-gatewayPublicSnapshotLimits.progressWatchEvents);
|
|
503
491
|
return projectGatewayPublicSnapshot({
|
|
504
492
|
...base,
|
|
505
493
|
generatedAt: now.toISOString(),
|
|
506
|
-
health:
|
|
507
|
-
? "degraded"
|
|
508
|
-
: connectors.length === 0
|
|
509
|
-
? "offline"
|
|
510
|
-
: "healthy",
|
|
494
|
+
health: word === "ok" || word === "stale" ? "healthy" : word,
|
|
511
495
|
connectors,
|
|
512
496
|
availablePeers,
|
|
513
497
|
routes,
|
|
514
|
-
progressWatches,
|
|
515
|
-
progressWatchEvents,
|
|
516
498
|
alerts: [...base.alerts, ...this.runtimeAlerts].slice(-gatewayPublicSnapshotLimits.alerts),
|
|
517
|
-
truncation:
|
|
518
|
-
...base.truncation,
|
|
519
|
-
progressWatches: Math.max(0, this.progressWatches.size - progressWatches.length),
|
|
520
|
-
progressWatchEvents: Math.max(0, this.progressWatchEvents.length - progressWatchEvents.length),
|
|
521
|
-
},
|
|
499
|
+
truncation: base.truncation,
|
|
522
500
|
}, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET);
|
|
523
501
|
}
|
|
524
502
|
async observeSnapshot() {
|
|
@@ -531,30 +509,29 @@ export class GatewayService {
|
|
|
531
509
|
return { snapshotRevision: this.snapshotRevision, snapshot };
|
|
532
510
|
}
|
|
533
511
|
health() {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
registrationMode: "explicit_opt_in",
|
|
512
|
+
const word = gatewayHealthWord(this.publicConnectors(this.now()));
|
|
513
|
+
return word === "ok" || word === "stale" ? "ok" : "degraded";
|
|
514
|
+
}
|
|
515
|
+
publicConnectors(now) {
|
|
516
|
+
return [...this.connectors.values()].map((runtime) => {
|
|
517
|
+
const observedAt = runtime.observedAt;
|
|
518
|
+
const age = observedAt === undefined ? undefined : Math.max(0, now.getTime() - Date.parse(observedAt));
|
|
519
|
+
const stale = runtime.health === "healthy" &&
|
|
520
|
+
(age === undefined || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS);
|
|
521
|
+
const socketHeld = runtime.adapter.identity.provider === "codex" && this.managedCodexSocketHeldOutside;
|
|
522
|
+
const connectorCode = runtime.safeErrorCode ??
|
|
523
|
+
(socketHeld ? "MANAGED_CODEX_UNAVAILABLE" : stale ? "CONNECTOR_OBSERVATION_STALE" : undefined);
|
|
524
|
+
return {
|
|
525
|
+
provider: runtime.adapter.identity.provider,
|
|
526
|
+
host: runtime.adapter.identity.hostId,
|
|
527
|
+
health: stale || socketHeld ? "degraded" : runtime.health,
|
|
528
|
+
protocol: runtime.adapter.protocol,
|
|
529
|
+
protocolVersion: runtime.adapter.protocolVersion,
|
|
530
|
+
...(observedAt === undefined ? {} : { lastSeenAt: observedAt }),
|
|
531
|
+
...(age === undefined ? {} : { observationAgeMs: age }),
|
|
532
|
+
...(runtime.registry === undefined ? {} : { registry: runtime.registry }),
|
|
533
|
+
...(connectorCode === undefined ? {} : { safeErrorCode: connectorCode }),
|
|
534
|
+
};
|
|
558
535
|
});
|
|
559
536
|
}
|
|
560
537
|
async observeLoadedRoutes() {
|
|
@@ -598,18 +575,18 @@ export class GatewayService {
|
|
|
598
575
|
return this.adapterFor({ provider: "claude", hostId });
|
|
599
576
|
}
|
|
600
577
|
async advertise(route) {
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
await this.claudeAdapter(hostId)?.advertiseNativeSourcePeer?.({
|
|
578
|
+
if (route.registrationMode === "federated_peer")
|
|
579
|
+
return;
|
|
580
|
+
await this.claudeAdapter(route.binding.hostId)?.advertiseNativeSourcePeer?.({
|
|
604
581
|
alias: route.alias,
|
|
605
582
|
sourceProvider: route.binding.provider,
|
|
606
583
|
cwd: this.nativePeerCwd,
|
|
607
584
|
});
|
|
608
585
|
}
|
|
609
586
|
async unadvertise(route) {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
await this.claudeAdapter(hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
|
|
587
|
+
if (route.registrationMode === "federated_peer")
|
|
588
|
+
return;
|
|
589
|
+
await this.claudeAdapter(route.binding.hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
|
|
613
590
|
}
|
|
614
591
|
reconcileAdvertisement(route) {
|
|
615
592
|
void this.advertise(route).catch((error) => this.alert("NATIVE_ADVERTISEMENT_FAILED", route, error));
|
|
@@ -617,7 +594,67 @@ export class GatewayService {
|
|
|
617
594
|
async reconcileUnadvertisement(route) {
|
|
618
595
|
await this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
|
|
619
596
|
}
|
|
620
|
-
|
|
597
|
+
assertRouteNotCleaning(alias) {
|
|
598
|
+
if (this.routeCleanupAliases.has(alias))
|
|
599
|
+
throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
|
|
600
|
+
}
|
|
601
|
+
async retireRoute(alias) {
|
|
602
|
+
this.assertWritable();
|
|
603
|
+
const route = await this.store.inspectPrivateRoute(alias);
|
|
604
|
+
if (!route)
|
|
605
|
+
return { accepted: false, code: "not_found" };
|
|
606
|
+
if (route.registrationMode === "federated_peer" || route.binding.hostId !== this.config.hostId) {
|
|
607
|
+
throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A remote route is retired by its owner, not this broker.");
|
|
608
|
+
}
|
|
609
|
+
this.assertRouteNotCleaning(alias);
|
|
610
|
+
this.routeCleanupAliases.add(alias);
|
|
611
|
+
const expiry = this.ephemeralPeerExpiries.get(alias);
|
|
612
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
613
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
614
|
+
let removed = false;
|
|
615
|
+
try {
|
|
616
|
+
const result = await this.store.removeOwnedRouteAtomic({ alias, binding: route.binding,
|
|
617
|
+
activity: { operatorAction: true, action: "route_retired" } });
|
|
618
|
+
if (!result.removed)
|
|
619
|
+
return { accepted: false, code: "not_found" };
|
|
620
|
+
removed = true;
|
|
621
|
+
await this.finishSettlements(result.settlements);
|
|
622
|
+
const incident = (row) => row.sourceBinding?.registrationId === route.binding.registrationId || row.targetBinding?.registrationId === route.binding.registrationId;
|
|
623
|
+
for (const [id, conversation] of this.conversations)
|
|
624
|
+
if (incident(conversation))
|
|
625
|
+
this.conversations.delete(id);
|
|
626
|
+
for (const [id, rows] of this.pendingClaudeReplies) {
|
|
627
|
+
const retained = rows.filter((row) => !incident(row));
|
|
628
|
+
if (retained.length)
|
|
629
|
+
this.pendingClaudeReplies.set(id, retained);
|
|
630
|
+
else
|
|
631
|
+
this.pendingClaudeReplies.delete(id);
|
|
632
|
+
}
|
|
633
|
+
this.forgetRoute(route);
|
|
634
|
+
if (route.binding.provider === "claude") {
|
|
635
|
+
try {
|
|
636
|
+
await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
this.alert("ROUTE_RELEASE_FAILED", route, error);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
else
|
|
643
|
+
await this.reconcileUnadvertisement(route);
|
|
644
|
+
this.revision += 1;
|
|
645
|
+
return { accepted: true, code: "ok", settlements: {
|
|
646
|
+
cancelled: result.settlements.filter((row) => row.state === "cancelled").length,
|
|
647
|
+
ambiguous: result.settlements.filter((row) => row.state === "ambiguous").length,
|
|
648
|
+
unconfirmed: result.settlements.filter((row) => row.state === "unconfirmed").length,
|
|
649
|
+
} };
|
|
650
|
+
}
|
|
651
|
+
finally {
|
|
652
|
+
if (!removed && expiry !== undefined)
|
|
653
|
+
this.ephemeralPeerExpiries.set(alias, expiry);
|
|
654
|
+
this.routeCleanupAliases.delete(alias);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
async removeOwnedRoute(route) {
|
|
621
658
|
this.assertWritable();
|
|
622
659
|
const result = await this.store.removeOwnedRouteAtomic({
|
|
623
660
|
alias: route.alias,
|
|
@@ -625,16 +662,10 @@ export class GatewayService {
|
|
|
625
662
|
activity: { operatorAction: true },
|
|
626
663
|
});
|
|
627
664
|
if (!result.removed)
|
|
628
|
-
throw new BridgeError(
|
|
665
|
+
throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact route is absent.");
|
|
629
666
|
await this.finishSettlements(result.settlements);
|
|
630
|
-
this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
|
|
631
667
|
this.forgetRoute(route);
|
|
632
|
-
|
|
633
|
-
await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
|
|
634
|
-
}
|
|
635
|
-
else {
|
|
636
|
-
await this.reconcileUnadvertisement(route);
|
|
637
|
-
}
|
|
668
|
+
await this.reconcileUnadvertisement(route);
|
|
638
669
|
}
|
|
639
670
|
async recordActivity(kind, action, aliases, operatorAction) {
|
|
640
671
|
await this.store.recordActivity({
|
|
@@ -647,6 +678,7 @@ export class GatewayService {
|
|
|
647
678
|
}
|
|
648
679
|
async registerCodex(params) {
|
|
649
680
|
const existing = await this.store.inspectPrivateRoute(params.alias);
|
|
681
|
+
this.assertRouteNotCleaning(params.alias);
|
|
650
682
|
if (existing !== undefined && params.succeedsAlias === undefined) {
|
|
651
683
|
if (existing.binding.provider !== "codex" || existing.binding.routeHandle !== params.threadId) {
|
|
652
684
|
throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The alias belongs to another registration.");
|
|
@@ -667,6 +699,7 @@ export class GatewayService {
|
|
|
667
699
|
};
|
|
668
700
|
if (params.succeedsAlias === undefined) {
|
|
669
701
|
this.assertWritable();
|
|
702
|
+
this.assertRouteNotCleaning(params.alias);
|
|
670
703
|
await this.store.registerRoute(replacement);
|
|
671
704
|
const installed = (await this.store.inspectPrivateRoute(params.alias));
|
|
672
705
|
await this.recordActivity("registration", "codex_registered", [installed.alias], true);
|
|
@@ -688,6 +721,7 @@ export class GatewayService {
|
|
|
688
721
|
throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The succeeded registration is absent.");
|
|
689
722
|
}
|
|
690
723
|
this.assertWritable();
|
|
724
|
+
this.assertRouteNotCleaning(params.alias);
|
|
691
725
|
const result = await this.store.replaceCodexRegistrationAtomic({
|
|
692
726
|
oldAlias: params.succeedsAlias,
|
|
693
727
|
expectedOldRegistrationId: previous.binding.registrationId,
|
|
@@ -695,7 +729,6 @@ export class GatewayService {
|
|
|
695
729
|
activity: { operatorAction: true },
|
|
696
730
|
});
|
|
697
731
|
await this.finishSettlements(result.settlements);
|
|
698
|
-
this.settleWatchesForAlias(previous.alias, "endpoint_retired", "operator");
|
|
699
732
|
if (!result.idempotent) {
|
|
700
733
|
this.forgetRoute(previous);
|
|
701
734
|
this.reconcileUnadvertisement(previous);
|
|
@@ -706,7 +739,7 @@ export class GatewayService {
|
|
|
706
739
|
this.kick(params.alias);
|
|
707
740
|
}
|
|
708
741
|
async registerPeer(params) {
|
|
709
|
-
if (this.
|
|
742
|
+
if (this.routeCleanupAliases.has(params.alias)) {
|
|
710
743
|
throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
|
|
711
744
|
}
|
|
712
745
|
const existing = await this.store.inspectPrivateRoute(params.alias);
|
|
@@ -723,11 +756,17 @@ export class GatewayService {
|
|
|
723
756
|
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
724
757
|
}
|
|
725
758
|
const token = `peer_${randomBytes(24).toString("base64url")}`;
|
|
759
|
+
this.assertRouteNotCleaning(params.alias);
|
|
726
760
|
await this.store.registerRoute({ alias: params.alias, binding: {
|
|
727
761
|
provider: "peer", hostId: this.config.hostId,
|
|
728
762
|
routeHandle: peerHandle(process.getuid(), params.alias, token), registrationId: registrationId(),
|
|
729
|
-
}, registrationMode: "explicit_opt_in"
|
|
763
|
+
}, registrationMode: "explicit_opt_in",
|
|
764
|
+
...(params.ephemeral === true ? { ephemeral: true } : {}) });
|
|
730
765
|
const installed = (await this.store.inspectPrivateRoute(params.alias));
|
|
766
|
+
if (params.ephemeral === true) {
|
|
767
|
+
this.ephemeralPeerExpiries.set(installed.alias, this.now().getTime() + (params.ttlMs ?? GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS));
|
|
768
|
+
this.scheduleWake();
|
|
769
|
+
}
|
|
731
770
|
this.observeRoute(installed);
|
|
732
771
|
this.reconcileAdvertisement(installed);
|
|
733
772
|
return token;
|
|
@@ -745,20 +784,87 @@ export class GatewayService {
|
|
|
745
784
|
}
|
|
746
785
|
async unregisterPeer(params) {
|
|
747
786
|
const route = await this.assertPeer(params);
|
|
748
|
-
if (this.
|
|
787
|
+
if (this.routeCleanupAliases.has(route.alias))
|
|
749
788
|
throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
|
|
750
|
-
this.
|
|
789
|
+
this.routeCleanupAliases.add(route.alias);
|
|
790
|
+
// The expiry leaves the map before the removal, under the same cleanup
|
|
791
|
+
// guard, so a clock that fires mid-removal has nothing to act on; a
|
|
792
|
+
// removal that does not happen puts it back, so a refused unregister
|
|
793
|
+
// still leaves the registration to its own expiry.
|
|
794
|
+
const expiry = this.ephemeralPeerExpiries.get(route.alias);
|
|
795
|
+
this.ephemeralPeerExpiries.delete(route.alias);
|
|
796
|
+
this.ephemeralExpiryFailures.delete(route.alias);
|
|
797
|
+
let removed = false;
|
|
751
798
|
try {
|
|
752
799
|
const result = await this.store.removeOwnedRouteAtomic({ alias: route.alias, binding: route.binding });
|
|
753
800
|
if (!result.removed)
|
|
754
801
|
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
802
|
+
removed = true;
|
|
755
803
|
await this.finishSettlements(result.settlements);
|
|
756
|
-
this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
|
|
757
804
|
this.forgetRoute(route);
|
|
758
805
|
await this.reconcileUnadvertisement(route);
|
|
759
806
|
}
|
|
760
807
|
finally {
|
|
761
|
-
|
|
808
|
+
if (!removed && expiry !== undefined)
|
|
809
|
+
this.ephemeralPeerExpiries.set(route.alias, expiry);
|
|
810
|
+
this.routeCleanupAliases.delete(route.alias);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Retires every ephemeral peer registration whose lifetime has run out. It
|
|
815
|
+
* is the same removal an explicit `unregister-peer` performs — queued work
|
|
816
|
+
* is settled, not abandoned — minus the token check, because the authority
|
|
817
|
+
* here is the clock the broker set itself.
|
|
818
|
+
*
|
|
819
|
+
* The clock is keyed by alias, and an alias can be re-registered durably
|
|
820
|
+
* after its ephemeral holder left, so nothing is retired unless the route
|
|
821
|
+
* behind the alias is still an ephemeral one. A retirement that throws
|
|
822
|
+
* keeps its entry and is retried `EPHEMERAL_EXPIRY_RETRY_MS` later, with an
|
|
823
|
+
* alert each time; after `EPHEMERAL_EXPIRY_MAX_ATTEMPTS` consecutive
|
|
824
|
+
* failures the entry is dropped and the registration is left to the next
|
|
825
|
+
* restart, which clears it by construction.
|
|
826
|
+
*/
|
|
827
|
+
async expireEphemeralPeers(now) {
|
|
828
|
+
for (const [alias, expiresAt] of [...this.ephemeralPeerExpiries]) {
|
|
829
|
+
if (expiresAt > now)
|
|
830
|
+
continue;
|
|
831
|
+
// Another owner is mid-removal; the entry is theirs to clear or restore.
|
|
832
|
+
if (this.routeCleanupAliases.has(alias))
|
|
833
|
+
continue;
|
|
834
|
+
const route = await this.store.inspectPrivateRoute(alias);
|
|
835
|
+
if (route === undefined || !this.store.isEphemeralRegistration(route.binding.registrationId)) {
|
|
836
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
837
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
838
|
+
continue;
|
|
839
|
+
}
|
|
840
|
+
this.routeCleanupAliases.add(alias);
|
|
841
|
+
try {
|
|
842
|
+
const result = await this.store.removeOwnedRouteAtomic({ alias, binding: route.binding });
|
|
843
|
+
if (result.removed) {
|
|
844
|
+
await this.finishSettlements(result.settlements);
|
|
845
|
+
this.forgetRoute(route);
|
|
846
|
+
await this.reconcileUnadvertisement(route);
|
|
847
|
+
}
|
|
848
|
+
// Not removed means the binding changed underneath: the alias belongs
|
|
849
|
+
// to someone else now, and this clock is spent either way.
|
|
850
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
851
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
852
|
+
}
|
|
853
|
+
catch (error) {
|
|
854
|
+
this.alert("EPHEMERAL_ROUTE_EXPIRY_FAILED", route, error);
|
|
855
|
+
const attempts = (this.ephemeralExpiryFailures.get(alias) ?? 0) + 1;
|
|
856
|
+
if (attempts >= EPHEMERAL_EXPIRY_MAX_ATTEMPTS) {
|
|
857
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
858
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
this.ephemeralExpiryFailures.set(alias, attempts);
|
|
862
|
+
this.ephemeralPeerExpiries.set(alias, now + EPHEMERAL_EXPIRY_RETRY_MS);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
finally {
|
|
866
|
+
this.routeCleanupAliases.delete(alias);
|
|
867
|
+
}
|
|
762
868
|
}
|
|
763
869
|
}
|
|
764
870
|
async awaitPeer(params) {
|
|
@@ -775,7 +881,7 @@ export class GatewayService {
|
|
|
775
881
|
const route = await this.assertPeer(params);
|
|
776
882
|
const adapter = this.adapterFor(route.binding);
|
|
777
883
|
if (adapter?.acknowledgeReceipt({ alias: route.alias, ...route.binding, receipt: params.receipt }) === "rejected") {
|
|
778
|
-
|
|
884
|
+
return { accepted: false, code: "not_found" };
|
|
779
885
|
}
|
|
780
886
|
}
|
|
781
887
|
async unregisterCodex(params) {
|
|
@@ -785,33 +891,56 @@ export class GatewayService {
|
|
|
785
891
|
route.binding.routeHandle !== params.threadId) {
|
|
786
892
|
throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact Codex registration is absent.");
|
|
787
893
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
const
|
|
894
|
+
if (route.registrationMode === "federated_peer") {
|
|
895
|
+
throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node and cannot be unregistered here.");
|
|
896
|
+
}
|
|
897
|
+
await this.removeOwnedRoute(route);
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* The name this session may take. A name belongs to one route at a time, so
|
|
901
|
+
* when another Claude route already holds it and THAT route's session is
|
|
902
|
+
* still live, this session takes a disambiguated name rather than displacing
|
|
903
|
+
* a working session and cancelling its queued work. Twins that share a
|
|
904
|
+
* display name therefore both keep routes and both stay reachable by UUID;
|
|
905
|
+
* only a route whose session is actually gone is displaced.
|
|
906
|
+
*/
|
|
907
|
+
claimableAlias(candidate, routes) {
|
|
908
|
+
const holder = routes.find((route) => route.alias === candidate.alias &&
|
|
909
|
+
route.binding.provider === "claude" &&
|
|
910
|
+
!(route.binding.hostId === candidate.adapter.identity.hostId &&
|
|
911
|
+
route.binding.routeHandle === candidate.routeHandle));
|
|
912
|
+
if (holder === undefined)
|
|
913
|
+
return candidate.alias;
|
|
914
|
+
const holderIsLive = [...this.candidates.values()].some((row) => row.adapter.identity.hostId === holder.binding.hostId &&
|
|
915
|
+
row.routeHandle === holder.binding.routeHandle);
|
|
916
|
+
return holderIsLive
|
|
917
|
+
? disambiguatedAlias(candidate.alias, candidate.routeHandle)
|
|
918
|
+
: candidate.alias;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Installs (or brings up to date) the route of one discovered Claude
|
|
922
|
+
* session. The adapter pins the session's route handle, the workspace
|
|
923
|
+
* check keeps deliberately broad workspaces out, and the store reuses the
|
|
924
|
+
* registration of a session already bound under the same (host, UUID) so a
|
|
925
|
+
* re-anchored or renamed session keeps its identity and in-flight
|
|
926
|
+
* conversations. Any Claude route the install displaced is forgotten and
|
|
927
|
+
* released at the adapter; its work was settled `ENDPOINT_RETIRED` by the
|
|
928
|
+
* store — and only a route whose own session is gone is ever displaced.
|
|
929
|
+
*/
|
|
930
|
+
async installClaudeRoute(candidate) {
|
|
931
|
+
const adapter = candidate.adapter;
|
|
803
932
|
if (adapter.selectRoute === undefined) {
|
|
804
|
-
throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot
|
|
933
|
+
throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot install routes.");
|
|
805
934
|
}
|
|
806
|
-
await adapter.assertWorkspaceDisjoint?.(
|
|
807
|
-
const chosen = await adapter.selectRoute({ alias:
|
|
935
|
+
await adapter.assertWorkspaceDisjoint?.(candidate.routeHandle, this.store.rootDir);
|
|
936
|
+
const chosen = await adapter.selectRoute({ alias: candidate.alias, routeHandle: candidate.routeHandle });
|
|
808
937
|
this.assertWritable();
|
|
809
938
|
const before = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
|
|
810
|
-
const
|
|
811
|
-
|
|
939
|
+
const alias = this.claimableAlias(candidate, before);
|
|
940
|
+
const existing = before.find((route) => route.binding.hostId === adapter.identity.hostId &&
|
|
812
941
|
route.binding.routeHandle === chosen.routeHandle);
|
|
813
|
-
const
|
|
814
|
-
alias
|
|
942
|
+
const input = {
|
|
943
|
+
alias,
|
|
815
944
|
binding: {
|
|
816
945
|
provider: "claude",
|
|
817
946
|
hostId: adapter.identity.hostId,
|
|
@@ -819,114 +948,163 @@ export class GatewayService {
|
|
|
819
948
|
registrationId: existing?.binding.registrationId ?? registrationId(),
|
|
820
949
|
},
|
|
821
950
|
registrationMode: "selected_live_peer",
|
|
822
|
-
}
|
|
951
|
+
};
|
|
952
|
+
this.assertRouteNotCleaning(alias);
|
|
953
|
+
const result = await this.installWithConcurrentFirstSend(input, adapter.identity.hostId, chosen.routeHandle);
|
|
823
954
|
await this.finishSettlements(result.settlements);
|
|
824
955
|
const after = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
|
|
825
956
|
for (const prior of before) {
|
|
826
|
-
const retained = after.find((route) => route.
|
|
827
|
-
if (retained === undefined
|
|
828
|
-
|
|
957
|
+
const retained = after.find((route) => route.binding.registrationId === prior.binding.registrationId);
|
|
958
|
+
if (retained === undefined) {
|
|
959
|
+
// Displaced: the alias now names another session.
|
|
829
960
|
this.forgetRoute(prior);
|
|
830
961
|
void this.adapterFor(prior.binding)?.releaseRoute?.(prior.binding.routeHandle)
|
|
831
962
|
.catch((error) => this.alert("ROUTE_RELEASE_FAILED", prior, error));
|
|
832
963
|
}
|
|
964
|
+
else if (retained.alias !== prior.alias) {
|
|
965
|
+
// Renamed: identity kept, the stale display-only observation goes and
|
|
966
|
+
// open conversations follow the session to its new name.
|
|
967
|
+
this.routeObservations.delete(prior.alias);
|
|
968
|
+
this.renameConversationCoordinates(prior.alias, retained.alias, prior.binding.registrationId);
|
|
969
|
+
}
|
|
833
970
|
}
|
|
834
|
-
const installed = (await this.store.inspectPrivateRoute(
|
|
971
|
+
const installed = (await this.store.inspectPrivateRoute(alias));
|
|
835
972
|
this.observeRoute(installed);
|
|
836
|
-
this.routeObservations.set(
|
|
973
|
+
this.routeObservations.set(alias, {
|
|
837
974
|
route: installed.binding,
|
|
838
975
|
state: chosen.state,
|
|
839
976
|
observedAt: this.now().toISOString(),
|
|
840
977
|
});
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
await this.
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
const existed = await this.store.hasConsentEdge(endpoints);
|
|
870
|
-
const result = await this.store.removeConsentEdge(endpoints);
|
|
871
|
-
await this.finishSettlements(result.settlements);
|
|
872
|
-
for (const alias of aliases)
|
|
873
|
-
this.settleWatchesForAlias(alias, "pair_removed", "operator");
|
|
874
|
-
if (existed)
|
|
875
|
-
await this.recordActivity("pairing", "routes_unpaired", [...aliases], true);
|
|
876
|
-
for (const alias of aliases)
|
|
877
|
-
this.kick(alias);
|
|
878
|
-
}
|
|
879
|
-
async resolvePairEndpoints(params, maySelect) {
|
|
880
|
-
const raw = params.aliases;
|
|
881
|
-
const aliases = [raw[0], raw[1]];
|
|
882
|
-
if (maySelect && aliases.some((alias) => PUBLIC_ALIAS.test(alias)))
|
|
883
|
-
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
884
|
-
for (let index = 0; index < aliases.length; index += 1) {
|
|
885
|
-
if (maySelect && this.collidingClaudeAliases.has(aliases[index]))
|
|
886
|
-
throw new BridgeError("PEER_ALIAS_COLLISION", "The Claude alias is ambiguous.");
|
|
887
|
-
if (PUBLIC_ALIAS.test(aliases[index]))
|
|
888
|
-
continue;
|
|
889
|
-
const selected = await this.resolveClaudeSelector(aliases[index], maySelect);
|
|
890
|
-
if (selected === undefined)
|
|
891
|
-
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude selector is unavailable.");
|
|
892
|
-
if (maySelect)
|
|
893
|
-
await this.selectClaude({ alias: aliases[index] });
|
|
894
|
-
aliases[index] = selected.alias;
|
|
895
|
-
}
|
|
896
|
-
const left = await this.store.inspectPrivateRoute(aliases[0]);
|
|
897
|
-
const right = await this.store.inspectPrivateRoute(aliases[1]);
|
|
898
|
-
if (left === undefined || right === undefined) {
|
|
899
|
-
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The pair endpoint is absent.");
|
|
978
|
+
if (result.installed)
|
|
979
|
+
this.revision += 1;
|
|
980
|
+
this.kick(alias);
|
|
981
|
+
return installed;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Two first sends to the same never-installed session race: both read no
|
|
985
|
+
* route and both mint a registration, and the loser's write is refused
|
|
986
|
+
* because the identity is now bound. That is a race, not a conflict — the
|
|
987
|
+
* session is exactly the one the loser resolved — so re-read once and adopt
|
|
988
|
+
* the registration that won instead of failing an honest send.
|
|
989
|
+
*/
|
|
990
|
+
async installWithConcurrentFirstSend(input, hostId, routeHandle) {
|
|
991
|
+
try {
|
|
992
|
+
return await this.store.installClaudeRoute(input);
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
if (!(error instanceof BridgeError) || error.code !== "ROUTE_IDENTITY_ALREADY_REGISTERED")
|
|
996
|
+
throw error;
|
|
997
|
+
const bound = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
998
|
+
route.binding.hostId === hostId &&
|
|
999
|
+
route.binding.routeHandle === routeHandle);
|
|
1000
|
+
if (bound === undefined)
|
|
1001
|
+
throw error;
|
|
1002
|
+
return await this.store.installClaudeRoute({
|
|
1003
|
+
...input,
|
|
1004
|
+
binding: { ...input.binding, registrationId: bound.binding.registrationId },
|
|
1005
|
+
});
|
|
900
1006
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Looks a Claude selector up without writing anything. Callers that may
|
|
1010
|
+
* still refuse — a `--from` alias that must match the sending session, a
|
|
1011
|
+
* native sender whose adapter-reported name must match discovery — resolve
|
|
1012
|
+
* here first, refuse on the read, and only then materialize.
|
|
1013
|
+
*
|
|
1014
|
+
* THE ALIAS-COLLISION FENCE lives here (emb-94, moved into the send path by
|
|
1015
|
+
* emb-104). Invariant: discovery is refreshed inside every send that
|
|
1016
|
+
* addresses a Claude session, never only on the timer; a NAME currently
|
|
1017
|
+
* shared by more than one live session is refused with a hard,
|
|
1018
|
+
* non-retryable `PEER_ALIAS_COLLISION` naming that alias, so the broker
|
|
1019
|
+
* never picks first and never installs a route under an ambiguous name;
|
|
1020
|
+
* collisions stay sticky while a scan is incomplete
|
|
1021
|
+
* (`collidingClaudeAliases` retention in refreshClaudeDiscovery); and
|
|
1022
|
+
* `availablePeers` keeps filtering colliders so `status` never lists an
|
|
1023
|
+
* unaddressable name.
|
|
1024
|
+
*
|
|
1025
|
+
* The fence is a fence on NAMES, not on sessions. A session UUID is
|
|
1026
|
+
* unambiguous by construction, so a UUID selector — an explicit
|
|
1027
|
+
* `send --to <uuid>`, an identity-pinned route addressed by its own handle,
|
|
1028
|
+
* or a sender resolved from its inherited socket — passes the fence even
|
|
1029
|
+
* while its display name collides. That is the emb-94 escape hatch: the
|
|
1030
|
+
* operator can always reach an ambiguously named session, and a collision on
|
|
1031
|
+
* a *sender's* name never silences that sender, whose identity was attested
|
|
1032
|
+
* rather than typed. In-flight conversations are likewise unaffected:
|
|
1033
|
+
* `reply` resolves by exact binding.
|
|
1034
|
+
*/
|
|
1035
|
+
async lookUpClaudeRoute(selector, hostId) {
|
|
1036
|
+
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
1037
|
+
if (PUBLIC_ALIAS.test(selector) && this.collidingClaudeAliases.has(selector)) {
|
|
1038
|
+
throw new BridgeError("PEER_ALIAS_COLLISION", `The Claude alias ${selector} names more than one live session; rename one, or address the session by UUID.`);
|
|
1039
|
+
}
|
|
1040
|
+
const routed = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
1041
|
+
(hostId === undefined || route.binding.hostId === hostId) &&
|
|
1042
|
+
(route.alias === selector || route.binding.routeHandle === selector));
|
|
1043
|
+
const candidate = [...this.candidates.values()].find((row) => (hostId === undefined || row.adapter.identity.hostId === hostId) &&
|
|
1044
|
+
(row.routeHandle === selector || row.alias === selector));
|
|
1045
|
+
return { routed, candidate };
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* The name discovery currently shows for a looked-up session, which a caller
|
|
1049
|
+
* compares against the alias it claimed before anything is written.
|
|
1050
|
+
*/
|
|
1051
|
+
claimedAlias(resolution) {
|
|
1052
|
+
const alias = resolution.candidate?.alias ?? resolution.routed?.alias;
|
|
1053
|
+
if (alias === undefined) {
|
|
1054
|
+
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
|
|
1055
|
+
}
|
|
1056
|
+
return alias;
|
|
1057
|
+
}
|
|
1058
|
+
/** Installs the looked-up session's route if it is not already current. */
|
|
1059
|
+
async materializeClaudeRoute(resolution) {
|
|
1060
|
+
const { routed, candidate } = resolution;
|
|
1061
|
+
if (candidate === undefined) {
|
|
1062
|
+
if (routed !== undefined)
|
|
1063
|
+
return routed;
|
|
1064
|
+
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
|
|
1065
|
+
}
|
|
1066
|
+
if (routed !== undefined &&
|
|
1067
|
+
routed.binding.hostId === candidate.adapter.identity.hostId &&
|
|
1068
|
+
routed.binding.routeHandle === candidate.routeHandle &&
|
|
1069
|
+
routed.alias === this.claimableAlias(candidate, await this.store.listLogicalRoutes()))
|
|
1070
|
+
return routed;
|
|
1071
|
+
return await this.installClaudeRoute(candidate);
|
|
908
1072
|
}
|
|
909
1073
|
async assertThread(alias, threadId) {
|
|
910
1074
|
const route = await this.store.inspectPrivateRoute(alias);
|
|
911
|
-
|
|
1075
|
+
// No registration at all is its own condition — the state every Codex
|
|
1076
|
+
// task is in after a private state reset — and is never dressed up as a
|
|
1077
|
+
// thread mismatch, which would send the operator hunting a wrong id.
|
|
1078
|
+
if (route === undefined)
|
|
1079
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The route is not registered.");
|
|
1080
|
+
if (route.binding.provider !== "codex" || route.binding.routeHandle !== threadId) {
|
|
912
1081
|
throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the route.");
|
|
913
1082
|
}
|
|
914
1083
|
return route;
|
|
915
1084
|
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Direction follows the inherited principal, not the route table: a Claude
|
|
1087
|
+
* session or its target may have no route yet, because routes install on
|
|
1088
|
+
* first send. A Codex task sends to Claude sessions and shell peers; a
|
|
1089
|
+
* Claude session sends to Codex tasks and shell peers (Claude-to-Claude is
|
|
1090
|
+
* native); a shell peer sends to either.
|
|
1091
|
+
*/
|
|
916
1092
|
async send(params) {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
const target =
|
|
920
|
-
if (
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
1093
|
+
if (params.conversationId !== undefined)
|
|
1094
|
+
return await this.replyToConversation(params);
|
|
1095
|
+
const target = await this.store.inspectPrivateRoute(params.toAlias);
|
|
1096
|
+
if ("replyAddress" in params && params.replyAddress !== undefined) {
|
|
1097
|
+
// A UUID names a Claude session as surely as a name does: recognize it
|
|
1098
|
+
// here, or a Claude-to-Claude send addressed by UUID falls through to the
|
|
1099
|
+
// Codex path and is answered as an unknown target.
|
|
1100
|
+
const claudeTarget = target?.binding.provider === "claude" ||
|
|
1101
|
+
(target === undefined && [...this.candidates.values()].some((row) => row.alias === params.toAlias || row.routeHandle === params.toAlias));
|
|
1102
|
+
return claudeTarget ? { accepted: false, code: "route_mismatch" } : this.sendToCodex(params);
|
|
1103
|
+
}
|
|
1104
|
+
if ("threadId" in params && params.threadId !== undefined) {
|
|
1105
|
+
return target?.binding.provider === "codex" ? { accepted: false, code: "route_mismatch" } : this.sendToClaude(params);
|
|
1106
|
+
}
|
|
1107
|
+
return target === undefined || target.binding.provider === "claude" ? this.sendToClaude(params) : this.sendToCodex(params);
|
|
930
1108
|
}
|
|
931
1109
|
async sendToClaude(params) {
|
|
932
1110
|
try {
|
|
@@ -939,9 +1117,25 @@ export class GatewayService {
|
|
|
939
1117
|
if (source === undefined)
|
|
940
1118
|
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The source route is absent.");
|
|
941
1119
|
const direct = await this.store.inspectPrivateRoute(params.toAlias);
|
|
942
|
-
|
|
943
|
-
if (
|
|
944
|
-
|
|
1120
|
+
let target;
|
|
1121
|
+
if (direct?.binding.provider === "peer") {
|
|
1122
|
+
target = direct;
|
|
1123
|
+
}
|
|
1124
|
+
else {
|
|
1125
|
+
const seen = await this.lookUpClaudeRoute(params.toAlias);
|
|
1126
|
+
// Admission before materialization: a send that is already going to be
|
|
1127
|
+
// refused for its own reasons — too large, outside the deadline
|
|
1128
|
+
// window, rate limited, or into a full queue — must not install a
|
|
1129
|
+
// route or displace another session on its way to that refusal.
|
|
1130
|
+
await this.store.assertEnqueueAdmissible({
|
|
1131
|
+
sourceAlias: params.fromAlias,
|
|
1132
|
+
targetAlias: this.claimedAlias(seen),
|
|
1133
|
+
direction: directionId(source.binding.provider, "claude"),
|
|
1134
|
+
sourceRegistrationId: source.binding.registrationId,
|
|
1135
|
+
body: params.text,
|
|
1136
|
+
});
|
|
1137
|
+
target = await this.materializeClaudeRoute(seen);
|
|
1138
|
+
}
|
|
945
1139
|
return await this.enqueueConversation({
|
|
946
1140
|
sourceAlias: params.fromAlias,
|
|
947
1141
|
targetAlias: target.alias,
|
|
@@ -949,13 +1143,10 @@ export class GatewayService {
|
|
|
949
1143
|
expectsReply: params.expectsReply,
|
|
950
1144
|
expectedSourceBinding: source.binding,
|
|
951
1145
|
expectedTargetBinding: target.binding,
|
|
952
|
-
...(params.trackIdleMinutes === undefined
|
|
953
|
-
? {}
|
|
954
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
955
1146
|
});
|
|
956
1147
|
}
|
|
957
1148
|
catch (error) {
|
|
958
|
-
return
|
|
1149
|
+
return sendRefusal(error, "peerToken" in params);
|
|
959
1150
|
}
|
|
960
1151
|
}
|
|
961
1152
|
async sendToCodex(params) {
|
|
@@ -969,13 +1160,18 @@ export class GatewayService {
|
|
|
969
1160
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
|
|
970
1161
|
}
|
|
971
1162
|
else {
|
|
1163
|
+
// The sending session's identity comes from its inherited socket, never
|
|
1164
|
+
// from --from. Look the session up read-only first: a send that is
|
|
1165
|
+
// about to be refused must not install, rename, or displace a route,
|
|
1166
|
+
// and must not settle another session's queued work.
|
|
972
1167
|
const resolved = await this.claudeAdapter(aliasHost(params.fromAlias))?.resolveReplyAddress?.(params.replyAddress);
|
|
973
1168
|
if (resolved === undefined)
|
|
974
1169
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1170
|
+
const seen = await this.lookUpClaudeRoute(resolved.routeHandle, aliasHost(params.fromAlias));
|
|
1171
|
+
if (this.claimedAlias(seen) !== params.fromAlias) {
|
|
1172
|
+
throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The --from alias does not name the sending Claude session.");
|
|
1173
|
+
}
|
|
1174
|
+
source = await this.materializeClaudeRoute(seen);
|
|
979
1175
|
}
|
|
980
1176
|
const target = await this.store.inspectPrivateRoute(params.toAlias);
|
|
981
1177
|
if (source === undefined || target === undefined) {
|
|
@@ -991,23 +1187,26 @@ export class GatewayService {
|
|
|
991
1187
|
expectsReply: params.expectsReply,
|
|
992
1188
|
expectedSourceBinding: source.binding,
|
|
993
1189
|
expectedTargetBinding: target.binding,
|
|
994
|
-
...(params.trackIdleMinutes === undefined
|
|
995
|
-
? {}
|
|
996
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
997
1190
|
});
|
|
998
1191
|
}
|
|
999
1192
|
catch (error) {
|
|
1000
|
-
return
|
|
1193
|
+
return sendRefusal(error, "peerToken" in params);
|
|
1001
1194
|
}
|
|
1002
1195
|
}
|
|
1003
|
-
|
|
1196
|
+
/**
|
|
1197
|
+
* A send addressed by conversation token: the caller must own one end, the
|
|
1198
|
+
* other end is the binding the conversation recorded, and the caller's
|
|
1199
|
+
* principal is the same attested one every send carries.
|
|
1200
|
+
*/
|
|
1201
|
+
async replyToConversation(params) {
|
|
1004
1202
|
try {
|
|
1005
1203
|
this.assertWritable();
|
|
1006
1204
|
const conversation = this.conversations.get(params.conversationId);
|
|
1007
1205
|
if (conversation === undefined)
|
|
1008
1206
|
throw new BridgeError("CONVERSATION_NOT_FOUND", "The conversation is absent.");
|
|
1009
|
-
const
|
|
1010
|
-
const
|
|
1207
|
+
const caller = replyCaller(params);
|
|
1208
|
+
const callerBinding = await this.assertReplyCaller(conversation, caller);
|
|
1209
|
+
const callerAlias = caller.alias;
|
|
1011
1210
|
const targetAlias = callerAlias === conversation.sourceAlias
|
|
1012
1211
|
? conversation.targetAlias
|
|
1013
1212
|
: conversation.sourceAlias;
|
|
@@ -1017,27 +1216,9 @@ export class GatewayService {
|
|
|
1017
1216
|
if (targetBinding === undefined) {
|
|
1018
1217
|
throw new BridgeError("CONVERSATION_ROUTE_RETIRED", "The conversation endpoint is no longer available.");
|
|
1019
1218
|
}
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const enqueued = await this.enqueueNativeConversationReply({
|
|
1024
|
-
conversation,
|
|
1025
|
-
sourceAlias: callerAlias,
|
|
1026
|
-
sourceBinding: callerBinding,
|
|
1027
|
-
target: conversation.nativeTarget,
|
|
1028
|
-
text: params.text,
|
|
1029
|
-
exposeDeliveryToken: true,
|
|
1030
|
-
});
|
|
1031
|
-
if (enqueued.deliveryToken === undefined) {
|
|
1032
|
-
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1033
|
-
}
|
|
1034
|
-
return {
|
|
1035
|
-
accepted: true,
|
|
1036
|
-
code: "ok",
|
|
1037
|
-
conversationId: conversation.id,
|
|
1038
|
-
deliveryToken: enqueued.deliveryToken,
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1219
|
+
// The target's route was installed when it first sent or was first
|
|
1220
|
+
// sent to; a reply never re-resolves it by alias. A retired binding is
|
|
1221
|
+
// a refusal at enqueue, never a silent retarget.
|
|
1041
1222
|
return await this.enqueueConversation({
|
|
1042
1223
|
sourceAlias: callerAlias,
|
|
1043
1224
|
targetAlias,
|
|
@@ -1045,14 +1226,11 @@ export class GatewayService {
|
|
|
1045
1226
|
expectsReply: true,
|
|
1046
1227
|
expectedSourceBinding: callerBinding,
|
|
1047
1228
|
expectedTargetBinding: targetBinding,
|
|
1048
|
-
...(params.trackIdleMinutes === undefined
|
|
1049
|
-
? {}
|
|
1050
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
1051
1229
|
existingConversation: conversation,
|
|
1052
1230
|
});
|
|
1053
1231
|
}
|
|
1054
1232
|
catch (error) {
|
|
1055
|
-
return
|
|
1233
|
+
return sendRefusal(error, params.peerToken !== undefined);
|
|
1056
1234
|
}
|
|
1057
1235
|
}
|
|
1058
1236
|
async assertReplyCaller(conversation, caller) {
|
|
@@ -1071,7 +1249,7 @@ export class GatewayService {
|
|
|
1071
1249
|
throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the conversation.");
|
|
1072
1250
|
}
|
|
1073
1251
|
if (caller.kind === "claude") {
|
|
1074
|
-
if (
|
|
1252
|
+
if (route.binding.provider !== "claude") {
|
|
1075
1253
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
|
|
1076
1254
|
}
|
|
1077
1255
|
const resolved = await this.claudeAdapter(route.binding.hostId)
|
|
@@ -1117,14 +1295,8 @@ export class GatewayService {
|
|
|
1117
1295
|
...(sourceBinding === undefined ? {} : { sourceBinding: { ...sourceBinding } }),
|
|
1118
1296
|
...(targetBinding === undefined ? {} : { targetBinding: { ...targetBinding } }),
|
|
1119
1297
|
expectsReply: input.expectsReply,
|
|
1120
|
-
pair: true,
|
|
1121
1298
|
nextSequence: 0,
|
|
1122
1299
|
};
|
|
1123
|
-
const previousWatch = this.progressWatches.get(conversation.id);
|
|
1124
|
-
const previousWatchEvents = this.progressWatchEvents.length;
|
|
1125
|
-
if (input.skipWatch !== true) {
|
|
1126
|
-
await this.updateProgressWatch(conversation, input.sourceAlias, input.text, input.trackIdleMinutes);
|
|
1127
|
-
}
|
|
1128
1300
|
const sequence = conversation.nextSequence++;
|
|
1129
1301
|
let enqueued;
|
|
1130
1302
|
try {
|
|
@@ -1145,12 +1317,10 @@ export class GatewayService {
|
|
|
1145
1317
|
});
|
|
1146
1318
|
}
|
|
1147
1319
|
catch (error) {
|
|
1148
|
-
this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
|
|
1149
1320
|
conversation.nextSequence -= 1;
|
|
1150
1321
|
throw error;
|
|
1151
1322
|
}
|
|
1152
1323
|
if (!enqueued.accepted || enqueued.messageId === undefined || enqueued.deliveryToken === undefined) {
|
|
1153
|
-
this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
|
|
1154
1324
|
conversation.nextSequence -= 1;
|
|
1155
1325
|
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1156
1326
|
}
|
|
@@ -1173,6 +1343,23 @@ export class GatewayService {
|
|
|
1173
1343
|
deliveryToken: enqueued.deliveryToken,
|
|
1174
1344
|
};
|
|
1175
1345
|
}
|
|
1346
|
+
/**
|
|
1347
|
+
* A renamed Claude session is the same principal under a new display name,
|
|
1348
|
+
* so its open conversations follow it: the registration, not the alias, is
|
|
1349
|
+
* the identity, and a reply under the current name must still resolve.
|
|
1350
|
+
*/
|
|
1351
|
+
renameConversationCoordinates(oldAlias, newAlias, registrationId) {
|
|
1352
|
+
for (const conversation of this.conversations.values()) {
|
|
1353
|
+
if (conversation.sourceAlias === oldAlias &&
|
|
1354
|
+
conversation.sourceBinding?.registrationId === registrationId) {
|
|
1355
|
+
conversation.sourceAlias = newAlias;
|
|
1356
|
+
}
|
|
1357
|
+
if (conversation.targetAlias === oldAlias &&
|
|
1358
|
+
conversation.targetBinding?.registrationId === registrationId) {
|
|
1359
|
+
conversation.targetAlias = newAlias;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1176
1363
|
rememberConversation(conversation) {
|
|
1177
1364
|
this.conversations.delete(conversation.id);
|
|
1178
1365
|
this.conversations.set(conversation.id, conversation);
|
|
@@ -1235,12 +1422,7 @@ export class GatewayService {
|
|
|
1235
1422
|
if (!this.running || this.closing)
|
|
1236
1423
|
return;
|
|
1237
1424
|
const route = await this.store.inspectPrivateRoute(targetAlias);
|
|
1238
|
-
const
|
|
1239
|
-
const transientTarget = transient === undefined
|
|
1240
|
-
? undefined
|
|
1241
|
-
: this.conversations.get(transient.conversationId)?.nativeTarget;
|
|
1242
|
-
const registration = preferredRegistrationId ?? route?.binding.registrationId ??
|
|
1243
|
-
transientTarget?.binding.registrationId;
|
|
1425
|
+
const registration = preferredRegistrationId ?? route?.binding.registrationId;
|
|
1244
1426
|
if (registration === undefined)
|
|
1245
1427
|
return;
|
|
1246
1428
|
this.startRunner(targetAlias, registration, "any");
|
|
@@ -1321,219 +1503,216 @@ export class GatewayService {
|
|
|
1321
1503
|
const conversation = messageContext === undefined
|
|
1322
1504
|
? undefined
|
|
1323
1505
|
: this.conversations.get(messageContext.conversationId);
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
const
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
registrationMode: "selected_live_peer",
|
|
1333
|
-
enabled: true,
|
|
1334
|
-
}
|
|
1335
|
-
: (persistedTarget === undefined
|
|
1336
|
-
? undefined
|
|
1337
|
-
: persistedTarget);
|
|
1338
|
-
const source = await this.store.inspectPrivateRoute(attempt.sourceAlias);
|
|
1506
|
+
// Resolve by registration, not by the alias captured at reserve: a
|
|
1507
|
+
// session renamed between reserve and dispatch is the same route and
|
|
1508
|
+
// must still be delivered to, under whatever name it carries now.
|
|
1509
|
+
const installed = await this.store.listLogicalRoutes();
|
|
1510
|
+
const byRegistration = (registration, alias) => installed.find((route) => route.binding.registrationId === registration) ??
|
|
1511
|
+
installed.find((route) => route.alias === alias);
|
|
1512
|
+
const target = byRegistration(attempt.targetRegistrationId, attempt.targetAlias);
|
|
1513
|
+
const source = byRegistration(attempt.sourceRegistrationId, attempt.sourceAlias);
|
|
1339
1514
|
if (this.closing || !this.running) {
|
|
1340
1515
|
await this.settleAttemptForShutdown(attempt.messageId, attempt.attemptId);
|
|
1341
1516
|
return false;
|
|
1342
1517
|
}
|
|
1343
1518
|
if (target === undefined ||
|
|
1344
1519
|
target.binding.registrationId !== attempt.targetRegistrationId ||
|
|
1345
|
-
|
|
1520
|
+
source?.binding.registrationId !== attempt.sourceRegistrationId) {
|
|
1346
1521
|
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "ROUTE_UNREGISTERED");
|
|
1347
1522
|
return false;
|
|
1348
1523
|
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1524
|
+
return target.registrationMode === "federated_peer"
|
|
1525
|
+
? this.runPeerAttempt(attempt, source, target, conversation)
|
|
1526
|
+
: this.runProviderAttempt(attempt, source, target, conversation, messageContext);
|
|
1527
|
+
}
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
async runPeerAttempt(attempt, source, target, conversation) {
|
|
1531
|
+
const peer = this.peerClients.get(target.binding.hostId);
|
|
1532
|
+
if (peer === undefined || source.registrationMode === "federated_peer") {
|
|
1533
|
+
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
|
|
1534
|
+
return false;
|
|
1535
|
+
}
|
|
1536
|
+
const sourceEndpoint = { alias: source.alias, provider: source.binding.provider,
|
|
1537
|
+
host: source.binding.hostId, routeRef: peerRouteRef(source.binding.hostId, source.binding.registrationId) };
|
|
1538
|
+
const targetEndpoint = { alias: target.alias, provider: target.binding.provider,
|
|
1539
|
+
host: target.binding.hostId, routeRef: target.binding.routeHandle };
|
|
1540
|
+
const params = {
|
|
1541
|
+
originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
|
|
1542
|
+
source: sourceEndpoint, target: targetEndpoint,
|
|
1543
|
+
deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
|
|
1544
|
+
body: attempt.body,
|
|
1545
|
+
...(attempt.steer === true ? { steer: true } : {}),
|
|
1546
|
+
...(attempt.conversationIdSuffix === undefined ? {} : { conversationCorrelation: attempt.conversationIdSuffix }),
|
|
1547
|
+
};
|
|
1548
|
+
let armed = false, authorizationUncertain = false, acceptanceObserved = false, result;
|
|
1549
|
+
try {
|
|
1550
|
+
const prepared = peer.prepareHandoff(params);
|
|
1551
|
+
let authorized;
|
|
1552
|
+
try {
|
|
1553
|
+
authorized = await this.store.authorizeMessage({ messageId: attempt.messageId,
|
|
1554
|
+
attemptId: attempt.attemptId, sourceRegistrationId: attempt.sourceRegistrationId,
|
|
1555
|
+
targetRegistrationId: attempt.targetRegistrationId,
|
|
1556
|
+
prepared: { kind: "peer_handoff", bodyBytes: prepared.bodyBytes,
|
|
1557
|
+
bodySha256: prepared.bodySha256, frameBytes: prepared.frameBytes, sha256: prepared.sha256 } });
|
|
1558
|
+
}
|
|
1559
|
+
catch (error) {
|
|
1560
|
+
authorizationUncertain = true;
|
|
1561
|
+
throw error;
|
|
1562
|
+
}
|
|
1563
|
+
if (authorized.status !== "authorized") {
|
|
1564
|
+
prepared.cancel();
|
|
1565
|
+
if (authorized.status === "terminal")
|
|
1566
|
+
await this.finishSettlement(authorized.settlement);
|
|
1567
|
+
this.activeAttempts.delete(attempt.messageId);
|
|
1568
|
+
return false;
|
|
1569
|
+
}
|
|
1570
|
+
armed = true;
|
|
1571
|
+
await prepared.perform();
|
|
1572
|
+
acceptanceObserved = true;
|
|
1573
|
+
const accepted = await this.store.acceptMessage({ messageId: attempt.messageId,
|
|
1574
|
+
attemptId: attempt.attemptId, lossOutcome: "unconfirmed" });
|
|
1575
|
+
if (accepted.status !== "accepted")
|
|
1576
|
+
throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The peer acceptance fence is stale.");
|
|
1577
|
+
result = { state: "delivered", safeErrorCode: "PEER_HANDOFF_CONFIRMED" };
|
|
1578
|
+
}
|
|
1579
|
+
catch (error) {
|
|
1580
|
+
if (!armed && !authorizationUncertain) {
|
|
1581
|
+
peer.close();
|
|
1582
|
+
this.peerClients.delete(target.binding.hostId);
|
|
1583
|
+
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
|
|
1584
|
+
return false;
|
|
1585
|
+
}
|
|
1586
|
+
result = armed && !authorizationUncertain && !acceptanceObserved &&
|
|
1587
|
+
error instanceof PeerRequestError && error.detail.code === -32000 && isPeerHandoffRefusal(error.detail.data)
|
|
1588
|
+
? { state: "failed", safeErrorCode: error.detail.data.reason }
|
|
1589
|
+
: acceptanceObserved
|
|
1590
|
+
? { state: "unconfirmed", safeErrorCode: "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" }
|
|
1591
|
+
: { state: "ambiguous", safeErrorCode: authorizationUncertain ? "WRITE_AUTHORIZATION_UNCERTAIN" : "PEER_HANDOFF_OUTCOME_UNKNOWN" };
|
|
1592
|
+
}
|
|
1593
|
+
const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, source.alias, target.alias);
|
|
1594
|
+
this.activeAttempts.delete(attempt.messageId);
|
|
1595
|
+
return requeued;
|
|
1596
|
+
}
|
|
1597
|
+
async runProviderAttempt(attempt, source, target, conversation, messageContext) {
|
|
1598
|
+
const adapter = this.adapterFor(target.binding);
|
|
1599
|
+
if (adapter === undefined) {
|
|
1600
|
+
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "PROVIDER_UNAVAILABLE");
|
|
1601
|
+
return false;
|
|
1602
|
+
}
|
|
1603
|
+
const active = this.activeAttempts.get(attempt.messageId);
|
|
1604
|
+
let authorizationUncertain = false;
|
|
1605
|
+
let armed = false;
|
|
1606
|
+
let accepted = false;
|
|
1607
|
+
const conversationId = messageContext?.conversationId ??
|
|
1608
|
+
conversationIdForSuffix(attempt.conversationIdSuffix);
|
|
1609
|
+
const parsed = parseDirection(attempt.direction);
|
|
1610
|
+
if (target.binding.provider === "claude" &&
|
|
1611
|
+
messageContext?.expectsReply === true &&
|
|
1612
|
+
![...this.pendingClaudeReplies.values()].some((rows) => rows.some((row) => row.messageId === attempt.messageId)) &&
|
|
1613
|
+
[...this.pendingClaudeReplies.values()].reduce((count, rows) => count + rows.length, 0) >= MAX_PENDING_CLAUDE_REPLIES) {
|
|
1614
|
+
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "ROUTE_BUSY");
|
|
1615
|
+
return true;
|
|
1616
|
+
}
|
|
1617
|
+
let result;
|
|
1618
|
+
try {
|
|
1619
|
+
result = await adapter.dispatch({
|
|
1620
|
+
attemptId: attempt.attemptId,
|
|
1621
|
+
sourceAlias: source.alias,
|
|
1622
|
+
sourceProvider: parsed.sourceProvider,
|
|
1623
|
+
// The route's CURRENT alias, not the one captured at reserve. The
|
|
1624
|
+
// provider adapters compare this against the alias they hold for the
|
|
1625
|
+
// binding, so a session renamed between reserve and dispatch would
|
|
1626
|
+
// otherwise fail its own delivery over a stale display name.
|
|
1627
|
+
targetAlias: target.alias,
|
|
1628
|
+
conversationId,
|
|
1629
|
+
binding: target.binding,
|
|
1630
|
+
authorization: "selected_route",
|
|
1631
|
+
messageId: attempt.messageId,
|
|
1632
|
+
text: attempt.body,
|
|
1633
|
+
expectsReply: conversation?.expectsReply ?? true,
|
|
1634
|
+
deadlineAt: attempt.deadlineAt,
|
|
1635
|
+
...(attempt.steer === true ? { steer: true, queuedAhead: 0 } : {}),
|
|
1636
|
+
authorizeWrite: async (evidence) => {
|
|
1637
|
+
if (evidence.attemptId !== attempt.attemptId)
|
|
1638
|
+
return false;
|
|
1373
1639
|
try {
|
|
1374
|
-
authorized = await this.store.authorizeMessage({
|
|
1375
|
-
|
|
1640
|
+
const authorized = await this.store.authorizeMessage({
|
|
1641
|
+
messageId: attempt.messageId,
|
|
1642
|
+
attemptId: attempt.attemptId,
|
|
1643
|
+
sourceRegistrationId: attempt.sourceRegistrationId,
|
|
1376
1644
|
targetRegistrationId: attempt.targetRegistrationId,
|
|
1377
|
-
prepared: {
|
|
1378
|
-
|
|
1645
|
+
prepared: {
|
|
1646
|
+
kind: evidence.kind,
|
|
1647
|
+
bodyBytes: evidence.bodyBytes,
|
|
1648
|
+
bodySha256: evidence.bodySha256,
|
|
1649
|
+
frameBytes: evidence.frameBytes,
|
|
1650
|
+
sha256: evidence.sha256,
|
|
1651
|
+
},
|
|
1652
|
+
});
|
|
1653
|
+
if (authorized.status === "terminal") {
|
|
1654
|
+
await this.finishSettlement(authorized.settlement);
|
|
1655
|
+
}
|
|
1656
|
+
if (authorized.status === "authorized") {
|
|
1657
|
+
armed = true;
|
|
1658
|
+
if (target.binding.provider === "claude" &&
|
|
1659
|
+
messageContext?.expectsReply === true) {
|
|
1660
|
+
this.installPendingClaudeReply({
|
|
1661
|
+
messageId: attempt.messageId,
|
|
1662
|
+
conversationId,
|
|
1663
|
+
sourceAlias: source.alias,
|
|
1664
|
+
targetAlias: target.alias,
|
|
1665
|
+
sourceBinding: source.binding,
|
|
1666
|
+
targetBinding: target.binding,
|
|
1667
|
+
deadlineAt: attempt.deadlineAt,
|
|
1668
|
+
state: "armed",
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return authorized.status === "authorized";
|
|
1379
1673
|
}
|
|
1380
1674
|
catch (error) {
|
|
1381
1675
|
authorizationUncertain = true;
|
|
1382
1676
|
throw error;
|
|
1383
1677
|
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
return false;
|
|
1678
|
+
},
|
|
1679
|
+
onAccepted: async (evidence) => {
|
|
1680
|
+
if ((target.binding.provider !== "codex" && target.binding.provider !== "peer") ||
|
|
1681
|
+
evidence.attemptId !== attempt.attemptId) {
|
|
1682
|
+
throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The provider accepted another attempt.");
|
|
1390
1683
|
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
if (
|
|
1397
|
-
throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The
|
|
1398
|
-
result = { state: "delivered", safeErrorCode: "PEER_HANDOFF_CONFIRMED" };
|
|
1399
|
-
}
|
|
1400
|
-
catch {
|
|
1401
|
-
if (!armed && !authorizationUncertain) {
|
|
1402
|
-
peer.close();
|
|
1403
|
-
this.peerClients.delete(target.binding.hostId);
|
|
1404
|
-
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
|
|
1405
|
-
return false;
|
|
1684
|
+
const result = await this.store.acceptMessage({
|
|
1685
|
+
messageId: attempt.messageId,
|
|
1686
|
+
attemptId: attempt.attemptId,
|
|
1687
|
+
lossOutcome: attempt.steer === true ? "ambiguous" : "unconfirmed",
|
|
1688
|
+
});
|
|
1689
|
+
if (result.status !== "accepted") {
|
|
1690
|
+
throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The durable acceptance fence is stale.");
|
|
1406
1691
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
}
|
|
1411
|
-
const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, attempt.sourceAlias, attempt.targetAlias);
|
|
1412
|
-
this.activeAttempts.delete(attempt.messageId);
|
|
1413
|
-
return requeued;
|
|
1414
|
-
}
|
|
1415
|
-
const adapter = this.adapterFor(target.binding);
|
|
1416
|
-
if (adapter === undefined) {
|
|
1417
|
-
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "PROVIDER_UNAVAILABLE");
|
|
1418
|
-
return false;
|
|
1419
|
-
}
|
|
1420
|
-
const active = this.activeAttempts.get(attempt.messageId);
|
|
1421
|
-
let authorizationUncertain = false;
|
|
1422
|
-
let armed = false;
|
|
1423
|
-
let accepted = false;
|
|
1424
|
-
const conversationId = messageContext?.conversationId ??
|
|
1425
|
-
conversationIdForSuffix(attempt.conversationIdSuffix);
|
|
1426
|
-
const parsed = parseDirection(attempt.direction);
|
|
1427
|
-
if (target.binding.provider === "claude" &&
|
|
1428
|
-
messageContext?.expectsReply === true &&
|
|
1429
|
-
![...this.pendingClaudeReplies.values()].some((rows) => rows.some((row) => row.messageId === attempt.messageId)) &&
|
|
1430
|
-
[...this.pendingClaudeReplies.values()].reduce((count, rows) => count + rows.length, 0) >= MAX_PENDING_CLAUDE_REPLIES) {
|
|
1431
|
-
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "ROUTE_BUSY");
|
|
1432
|
-
return true;
|
|
1433
|
-
}
|
|
1434
|
-
let result;
|
|
1435
|
-
try {
|
|
1436
|
-
result = await adapter.dispatch({
|
|
1437
|
-
attemptId: attempt.attemptId,
|
|
1438
|
-
sourceAlias: attempt.sourceAlias,
|
|
1439
|
-
sourceProvider: parsed.sourceProvider,
|
|
1440
|
-
targetAlias: attempt.targetAlias,
|
|
1441
|
-
conversationId,
|
|
1442
|
-
binding: target.binding,
|
|
1443
|
-
authorization: transientTarget === undefined
|
|
1444
|
-
? "selected_route"
|
|
1445
|
-
: "native_reply",
|
|
1446
|
-
messageId: attempt.messageId,
|
|
1447
|
-
text: attempt.body,
|
|
1448
|
-
expectsReply: conversation?.expectsReply ?? true,
|
|
1449
|
-
deadlineAt: attempt.deadlineAt,
|
|
1450
|
-
...(this.progressWatches.has(conversationId)
|
|
1451
|
-
? { progressWatchActive: true }
|
|
1452
|
-
: {}),
|
|
1453
|
-
...(attempt.steer === true ? { steer: true, queuedAhead: 0 } : {}),
|
|
1454
|
-
authorizeWrite: async (evidence) => {
|
|
1455
|
-
if (evidence.attemptId !== attempt.attemptId)
|
|
1456
|
-
return false;
|
|
1457
|
-
try {
|
|
1458
|
-
const authorized = await this.store.authorizeMessage({
|
|
1459
|
-
messageId: attempt.messageId,
|
|
1460
|
-
attemptId: attempt.attemptId,
|
|
1461
|
-
sourceRegistrationId: attempt.sourceRegistrationId,
|
|
1462
|
-
targetRegistrationId: attempt.targetRegistrationId,
|
|
1463
|
-
prepared: {
|
|
1464
|
-
kind: evidence.kind,
|
|
1465
|
-
bodyBytes: evidence.bodyBytes,
|
|
1466
|
-
bodySha256: evidence.bodySha256,
|
|
1467
|
-
frameBytes: evidence.frameBytes,
|
|
1468
|
-
sha256: evidence.sha256,
|
|
1469
|
-
},
|
|
1470
|
-
});
|
|
1471
|
-
if (authorized.status === "terminal") {
|
|
1472
|
-
await this.finishSettlement(authorized.settlement);
|
|
1473
|
-
}
|
|
1474
|
-
if (authorized.status === "authorized") {
|
|
1475
|
-
armed = true;
|
|
1476
|
-
if (target.binding.provider === "claude" &&
|
|
1477
|
-
messageContext?.expectsReply === true &&
|
|
1478
|
-
source !== undefined) {
|
|
1479
|
-
this.installPendingClaudeReply({
|
|
1480
|
-
messageId: attempt.messageId,
|
|
1481
|
-
conversationId,
|
|
1482
|
-
sourceAlias: attempt.sourceAlias,
|
|
1483
|
-
targetAlias: attempt.targetAlias,
|
|
1484
|
-
sourceBinding: source.binding,
|
|
1485
|
-
targetBinding: target.binding,
|
|
1486
|
-
deadlineAt: attempt.deadlineAt,
|
|
1487
|
-
state: "armed",
|
|
1488
|
-
});
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
return authorized.status === "authorized";
|
|
1492
|
-
}
|
|
1493
|
-
catch (error) {
|
|
1494
|
-
authorizationUncertain = true;
|
|
1495
|
-
throw error;
|
|
1496
|
-
}
|
|
1497
|
-
},
|
|
1498
|
-
onAccepted: async (evidence) => {
|
|
1499
|
-
if ((target.binding.provider !== "codex" && target.binding.provider !== "peer") ||
|
|
1500
|
-
evidence.attemptId !== attempt.attemptId) {
|
|
1501
|
-
throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The provider accepted another attempt.");
|
|
1502
|
-
}
|
|
1503
|
-
const result = await this.store.acceptMessage({
|
|
1504
|
-
messageId: attempt.messageId,
|
|
1505
|
-
attemptId: attempt.attemptId,
|
|
1506
|
-
lossOutcome: attempt.steer === true ? "ambiguous" : "unconfirmed",
|
|
1507
|
-
});
|
|
1508
|
-
if (result.status !== "accepted") {
|
|
1509
|
-
throw new BridgeError("ACCEPTANCE_UNCONFIRMED", "The durable acceptance fence is stale.");
|
|
1510
|
-
}
|
|
1511
|
-
accepted = true;
|
|
1512
|
-
},
|
|
1513
|
-
});
|
|
1514
|
-
}
|
|
1515
|
-
catch {
|
|
1516
|
-
result = {
|
|
1517
|
-
state: accepted
|
|
1518
|
-
? attempt.steer === true
|
|
1519
|
-
? "ambiguous"
|
|
1520
|
-
: "unconfirmed"
|
|
1521
|
-
: armed || authorizationUncertain
|
|
1522
|
-
? "ambiguous"
|
|
1523
|
-
: "failed",
|
|
1524
|
-
safeErrorCode: accepted
|
|
1525
|
-
? "DELIVERY_UNCONFIRMED"
|
|
1526
|
-
: authorizationUncertain || armed
|
|
1527
|
-
? "WRITE_AUTHORIZATION_UNCERTAIN"
|
|
1528
|
-
: "PROVIDER_DISPATCH_FAILED",
|
|
1529
|
-
};
|
|
1530
|
-
}
|
|
1531
|
-
const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, attempt.sourceAlias, attempt.targetAlias);
|
|
1532
|
-
if (this.activeAttempts.get(attempt.messageId) === active)
|
|
1533
|
-
this.activeAttempts.delete(attempt.messageId);
|
|
1534
|
-
return requeued;
|
|
1692
|
+
accepted = true;
|
|
1693
|
+
},
|
|
1694
|
+
});
|
|
1535
1695
|
}
|
|
1536
|
-
|
|
1696
|
+
catch {
|
|
1697
|
+
result = {
|
|
1698
|
+
state: accepted
|
|
1699
|
+
? attempt.steer === true
|
|
1700
|
+
? "ambiguous"
|
|
1701
|
+
: "unconfirmed"
|
|
1702
|
+
: armed || authorizationUncertain
|
|
1703
|
+
? "ambiguous"
|
|
1704
|
+
: "failed",
|
|
1705
|
+
safeErrorCode: accepted
|
|
1706
|
+
? "DELIVERY_UNCONFIRMED"
|
|
1707
|
+
: authorizationUncertain || armed
|
|
1708
|
+
? "WRITE_AUTHORIZATION_UNCERTAIN"
|
|
1709
|
+
: "PROVIDER_DISPATCH_FAILED",
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
const requeued = await this.applyDispatchResult(attempt.messageId, attempt.attemptId, result, armed, conversation, source.alias, target.alias);
|
|
1713
|
+
if (this.activeAttempts.get(attempt.messageId) === active)
|
|
1714
|
+
this.activeAttempts.delete(attempt.messageId);
|
|
1715
|
+
return requeued;
|
|
1537
1716
|
}
|
|
1538
1717
|
async settleAttemptForShutdown(messageId, attemptId) {
|
|
1539
1718
|
const result = await this.store.settleAttemptForShutdown({ messageId, attemptId });
|
|
@@ -1659,7 +1838,6 @@ export class GatewayService {
|
|
|
1659
1838
|
}
|
|
1660
1839
|
}
|
|
1661
1840
|
this.revision += 1;
|
|
1662
|
-
void this.publish().catch(() => undefined);
|
|
1663
1841
|
}
|
|
1664
1842
|
async onClaudeMessage(event) {
|
|
1665
1843
|
const target = await this.store.inspectPrivateRoute(event.targetAlias);
|
|
@@ -1668,21 +1846,23 @@ export class GatewayService {
|
|
|
1668
1846
|
!(target.registrationMode === "federated_peer" && event.endpoint.hostId === localHost))) {
|
|
1669
1847
|
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The native target is absent.");
|
|
1670
1848
|
}
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
}
|
|
1849
|
+
// A native SendMessage is this session's send: its route installs here,
|
|
1850
|
+
// from the exact identity the adapter attested, so the Codex task can
|
|
1851
|
+
// reply through the ordinary path. The adapter's name for the sender must
|
|
1852
|
+
// be the name discovery shows for that UUID; the check runs on a read-only
|
|
1853
|
+
// lookup so a refusal writes nothing.
|
|
1854
|
+
this.assertWritable();
|
|
1855
|
+
const seen = await this.lookUpClaudeRoute(event.endpoint.routeHandle, event.endpoint.hostId);
|
|
1856
|
+
if (this.claimedAlias(seen) !== event.sourceAlias) {
|
|
1857
|
+
throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The native sender's name does not match its discovered session.");
|
|
1858
|
+
}
|
|
1859
|
+
const source = await this.materializeClaudeRoute(seen);
|
|
1860
|
+
const sourceBinding = source.binding;
|
|
1681
1861
|
const conversationId = createGatewayConversationId();
|
|
1682
1862
|
const steer = target.binding.provider === "codex" && event.text.startsWith("STEER:") && this.config.steeringEnabled;
|
|
1683
1863
|
this.assertWritable();
|
|
1684
1864
|
const enqueued = await this.store.enqueueNativeIngress({
|
|
1685
|
-
source: { alias:
|
|
1865
|
+
source: { alias: source.alias, binding: sourceBinding },
|
|
1686
1866
|
targetAlias: event.targetAlias,
|
|
1687
1867
|
expectedTargetRegistrationId: target.binding.registrationId,
|
|
1688
1868
|
body: event.text,
|
|
@@ -1695,13 +1875,11 @@ export class GatewayService {
|
|
|
1695
1875
|
}
|
|
1696
1876
|
const conversation = {
|
|
1697
1877
|
id: conversationId,
|
|
1698
|
-
sourceAlias:
|
|
1878
|
+
sourceAlias: source.alias,
|
|
1699
1879
|
targetAlias: event.targetAlias,
|
|
1700
1880
|
sourceBinding,
|
|
1701
1881
|
targetBinding: target.binding,
|
|
1702
1882
|
expectsReply: true,
|
|
1703
|
-
...(enqueued.pair === true ? { pair: true } : {}),
|
|
1704
|
-
nativeTarget: { alias: event.sourceAlias, binding: sourceBinding },
|
|
1705
1883
|
nextSequence: 1,
|
|
1706
1884
|
};
|
|
1707
1885
|
this.rememberConversation(conversation);
|
|
@@ -1806,65 +1984,21 @@ export class GatewayService {
|
|
|
1806
1984
|
const targetBinding = targetAlias === conversation.targetAlias
|
|
1807
1985
|
? conversation.targetBinding
|
|
1808
1986
|
: conversation.sourceBinding;
|
|
1809
|
-
if (
|
|
1810
|
-
sourceAlias !== conversation.nativeTarget.alias) {
|
|
1811
|
-
if (sourceBinding === undefined || targetBinding === undefined)
|
|
1812
|
-
return;
|
|
1813
|
-
const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
|
|
1814
|
-
if (currentSource === undefined ||
|
|
1815
|
-
!sameBinding(currentSource.binding, sourceBinding))
|
|
1816
|
-
return;
|
|
1817
|
-
await this.enqueueConversation({
|
|
1818
|
-
sourceAlias: targetAlias,
|
|
1819
|
-
targetAlias: sourceAlias,
|
|
1820
|
-
text,
|
|
1821
|
-
expectsReply: true,
|
|
1822
|
-
expectedSourceBinding: targetBinding,
|
|
1823
|
-
expectedTargetBinding: sourceBinding,
|
|
1824
|
-
existingConversation: conversation,
|
|
1825
|
-
});
|
|
1987
|
+
if (sourceBinding === undefined || targetBinding === undefined)
|
|
1826
1988
|
return;
|
|
1827
|
-
|
|
1828
|
-
if (
|
|
1989
|
+
const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
|
|
1990
|
+
if (currentSource === undefined ||
|
|
1991
|
+
!sameBinding(currentSource.binding, sourceBinding))
|
|
1829
1992
|
return;
|
|
1830
|
-
await this.
|
|
1831
|
-
conversation,
|
|
1993
|
+
await this.enqueueConversation({
|
|
1832
1994
|
sourceAlias: targetAlias,
|
|
1833
|
-
|
|
1834
|
-
target: conversation.nativeTarget,
|
|
1995
|
+
targetAlias: sourceAlias,
|
|
1835
1996
|
text,
|
|
1836
|
-
});
|
|
1837
|
-
}
|
|
1838
|
-
async enqueueNativeConversationReply(input) {
|
|
1839
|
-
const sequence = input.conversation.nextSequence++;
|
|
1840
|
-
let enqueued;
|
|
1841
|
-
try {
|
|
1842
|
-
enqueued = await this.store.enqueueNativeReply({
|
|
1843
|
-
sourceAlias: input.sourceAlias,
|
|
1844
|
-
expectedSourceRegistrationId: input.sourceBinding.registrationId,
|
|
1845
|
-
target: input.target,
|
|
1846
|
-
body: input.text,
|
|
1847
|
-
dedupeKey: `${input.conversation.id}:${sequence}`,
|
|
1848
|
-
conversationIdSuffix: input.conversation.id.slice(-8),
|
|
1849
|
-
...(input.conversation.pair === true ? { pair: true } : {}),
|
|
1850
|
-
...(input.exposeDeliveryToken === true ? { exposeDeliveryToken: true } : {}),
|
|
1851
|
-
});
|
|
1852
|
-
}
|
|
1853
|
-
catch (error) {
|
|
1854
|
-
input.conversation.nextSequence -= 1;
|
|
1855
|
-
throw error;
|
|
1856
|
-
}
|
|
1857
|
-
if (!enqueued.accepted || enqueued.messageId === undefined) {
|
|
1858
|
-
input.conversation.nextSequence -= 1;
|
|
1859
|
-
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1860
|
-
}
|
|
1861
|
-
this.messageContexts.set(enqueued.messageId, {
|
|
1862
|
-
conversationId: input.conversation.id,
|
|
1863
1997
|
expectsReply: true,
|
|
1998
|
+
expectedSourceBinding: targetBinding,
|
|
1999
|
+
expectedTargetBinding: sourceBinding,
|
|
2000
|
+
existingConversation: conversation,
|
|
1864
2001
|
});
|
|
1865
|
-
this.kick(input.target.alias, input.target.binding.registrationId);
|
|
1866
|
-
this.scheduleWake();
|
|
1867
|
-
return enqueued;
|
|
1868
2002
|
}
|
|
1869
2003
|
installPendingClaudeReply(row) {
|
|
1870
2004
|
const key = `${row.targetBinding.hostId}\0${row.targetBinding.routeHandle}`;
|
|
@@ -1931,173 +2065,6 @@ export class GatewayService {
|
|
|
1931
2065
|
existingConversation: conversation,
|
|
1932
2066
|
});
|
|
1933
2067
|
}
|
|
1934
|
-
async updateProgressWatch(conversation, actorAlias, text, trackIdleMinutes) {
|
|
1935
|
-
const existing = this.progressWatches.get(conversation.id);
|
|
1936
|
-
if (this.config.trackingEnabled === false) {
|
|
1937
|
-
if (existing !== undefined) {
|
|
1938
|
-
this.settleProgressWatch(existing, "gateway", "tracking_disabled");
|
|
1939
|
-
}
|
|
1940
|
-
return;
|
|
1941
|
-
}
|
|
1942
|
-
if (text.startsWith("DONE:")) {
|
|
1943
|
-
if (existing !== undefined) {
|
|
1944
|
-
const actor = actorAlias === existing.ownerAlias
|
|
1945
|
-
? "owner"
|
|
1946
|
-
: actorAlias === existing.workerAlias
|
|
1947
|
-
? "worker"
|
|
1948
|
-
: undefined;
|
|
1949
|
-
if (actor !== undefined)
|
|
1950
|
-
this.settleProgressWatch(existing, actor, "done");
|
|
1951
|
-
}
|
|
1952
|
-
return;
|
|
1953
|
-
}
|
|
1954
|
-
const explicit = text.startsWith("TRACK:") || trackIdleMinutes !== undefined;
|
|
1955
|
-
if (existing !== undefined && !explicit) {
|
|
1956
|
-
this.progressWatches.set(conversation.id, {
|
|
1957
|
-
...existing,
|
|
1958
|
-
...recordProgressWatchActivity(existing, this.now().getTime()),
|
|
1959
|
-
});
|
|
1960
|
-
return;
|
|
1961
|
-
}
|
|
1962
|
-
if (!explicit)
|
|
1963
|
-
return;
|
|
1964
|
-
if (existing !== undefined && actorAlias !== existing.ownerAlias) {
|
|
1965
|
-
throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the progress-watch owner may replace its watch.");
|
|
1966
|
-
}
|
|
1967
|
-
const capacity = Math.min(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY);
|
|
1968
|
-
if (existing === undefined && this.progressWatches.size >= capacity) {
|
|
1969
|
-
throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "Progress-watch capacity is full.");
|
|
1970
|
-
}
|
|
1971
|
-
const owner = await this.store.inspectPrivateRoute(actorAlias);
|
|
1972
|
-
const workerAlias = actorAlias === conversation.sourceAlias
|
|
1973
|
-
? conversation.targetAlias
|
|
1974
|
-
: conversation.sourceAlias;
|
|
1975
|
-
const worker = await this.store.inspectPrivateRoute(workerAlias);
|
|
1976
|
-
if (owner === undefined || worker === undefined) {
|
|
1977
|
-
throw new BridgeError("PROGRESS_WATCH_ENDPOINT_NOT_FOUND", "A progress-watch endpoint is absent.");
|
|
1978
|
-
}
|
|
1979
|
-
const idleMs = trackIdleMinutes === undefined
|
|
1980
|
-
? PROGRESS_WATCH_DEFAULT_IDLE_MS
|
|
1981
|
-
: trackIdleMinutes * 60_000;
|
|
1982
|
-
const watch = createProgressWatch({
|
|
1983
|
-
conversationId: conversation.id,
|
|
1984
|
-
ownerAlias: actorAlias,
|
|
1985
|
-
workerAlias,
|
|
1986
|
-
idleMs,
|
|
1987
|
-
at: this.now().getTime(),
|
|
1988
|
-
});
|
|
1989
|
-
this.progressWatches.set(conversation.id, {
|
|
1990
|
-
...watch,
|
|
1991
|
-
ownerRegistrationId: owner.binding.registrationId,
|
|
1992
|
-
workerRegistrationId: worker.binding.registrationId,
|
|
1993
|
-
});
|
|
1994
|
-
this.recordProgressWatchEvent({
|
|
1995
|
-
watch,
|
|
1996
|
-
kind: existing === undefined ? "opened" : "replaced",
|
|
1997
|
-
actor: "owner",
|
|
1998
|
-
});
|
|
1999
|
-
}
|
|
2000
|
-
restoreProgressWatch(conversationId, previous, eventLength) {
|
|
2001
|
-
if (previous === undefined)
|
|
2002
|
-
this.progressWatches.delete(conversationId);
|
|
2003
|
-
else
|
|
2004
|
-
this.progressWatches.set(conversationId, previous);
|
|
2005
|
-
this.progressWatchEvents.length = eventLength;
|
|
2006
|
-
}
|
|
2007
|
-
untrack(conversationId) {
|
|
2008
|
-
const watch = this.progressWatches.get(conversationId);
|
|
2009
|
-
if (watch === undefined) {
|
|
2010
|
-
throw new BridgeError("PROGRESS_WATCH_NOT_FOUND", "The progress watch is absent.");
|
|
2011
|
-
}
|
|
2012
|
-
this.settleProgressWatch(watch, "operator", "untracked");
|
|
2013
|
-
}
|
|
2014
|
-
settleWatchesForAlias(alias, reason, actor) {
|
|
2015
|
-
for (const watch of [...this.progressWatches.values()]) {
|
|
2016
|
-
if (watch.ownerAlias === alias || watch.workerAlias === alias) {
|
|
2017
|
-
this.settleProgressWatch(watch, actor, reason);
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
settleProgressWatch(watch, actor, reason) {
|
|
2022
|
-
if (!this.progressWatches.delete(watch.conversationId))
|
|
2023
|
-
return;
|
|
2024
|
-
this.recordProgressWatchEvent({ watch, kind: "settled", actor, reason });
|
|
2025
|
-
}
|
|
2026
|
-
recordProgressWatchEvent(input) {
|
|
2027
|
-
this.progressWatchEvents.push({
|
|
2028
|
-
sequence: (this.progressWatchEvents.at(-1)?.sequence ?? 0) + 1,
|
|
2029
|
-
timestamp: this.now().toISOString(),
|
|
2030
|
-
conversationIdSuffix: input.watch.conversationId.slice(-8),
|
|
2031
|
-
ownerAlias: input.watch.ownerAlias,
|
|
2032
|
-
workerAlias: input.watch.workerAlias,
|
|
2033
|
-
kind: input.kind,
|
|
2034
|
-
actor: input.actor,
|
|
2035
|
-
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
2036
|
-
});
|
|
2037
|
-
while (this.progressWatchEvents.length > gatewayPublicSnapshotLimits.progressWatchEvents) {
|
|
2038
|
-
this.progressWatchEvents.shift();
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
async processProgressWatches() {
|
|
2042
|
-
const at = this.now().getTime();
|
|
2043
|
-
for (const watch of [...this.progressWatches.values()]) {
|
|
2044
|
-
const owner = await this.store.inspectPrivateRoute(watch.ownerAlias);
|
|
2045
|
-
const worker = await this.store.inspectPrivateRoute(watch.workerAlias);
|
|
2046
|
-
if (owner?.binding.registrationId !== watch.ownerRegistrationId ||
|
|
2047
|
-
worker?.binding.registrationId !== watch.workerRegistrationId) {
|
|
2048
|
-
this.settleProgressWatch(watch, "gateway", "endpoint_retired");
|
|
2049
|
-
continue;
|
|
2050
|
-
}
|
|
2051
|
-
const ownerObservation = this.routeObservations.get(watch.ownerAlias);
|
|
2052
|
-
const workerObservation = this.routeObservations.get(watch.workerAlias);
|
|
2053
|
-
const due = inspectProgressWatchDue(watch, {
|
|
2054
|
-
at,
|
|
2055
|
-
bothIdle: ownerObservation?.state === "idle" &&
|
|
2056
|
-
workerObservation?.state === "idle" &&
|
|
2057
|
-
this.routeObservationStillCurrent(ownerObservation) &&
|
|
2058
|
-
this.routeObservationStillCurrent(workerObservation),
|
|
2059
|
-
});
|
|
2060
|
-
if (due.kind === "not_due")
|
|
2061
|
-
continue;
|
|
2062
|
-
if (due.kind === "rescheduled") {
|
|
2063
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2064
|
-
...watch,
|
|
2065
|
-
...due.watch,
|
|
2066
|
-
});
|
|
2067
|
-
continue;
|
|
2068
|
-
}
|
|
2069
|
-
if (due.kind === "settled") {
|
|
2070
|
-
this.settleProgressWatch(watch, "gateway", "idle_timeout");
|
|
2071
|
-
continue;
|
|
2072
|
-
}
|
|
2073
|
-
const conversation = this.conversations.get(watch.conversationId);
|
|
2074
|
-
if (conversation === undefined) {
|
|
2075
|
-
this.settleProgressWatch(watch, "gateway", "endpoint_retired");
|
|
2076
|
-
continue;
|
|
2077
|
-
}
|
|
2078
|
-
const text = `[Embassy automated liveness check — ${due.nudgeNumber} / Embassy 自动活跃检查 — ${due.nudgeNumber}]\nReply with the result or a status. / 请回复结果或状态。`;
|
|
2079
|
-
try {
|
|
2080
|
-
await this.enqueueConversation({
|
|
2081
|
-
sourceAlias: watch.ownerAlias,
|
|
2082
|
-
targetAlias: watch.workerAlias,
|
|
2083
|
-
text,
|
|
2084
|
-
expectsReply: false,
|
|
2085
|
-
existingConversation: conversation,
|
|
2086
|
-
skipWatch: true,
|
|
2087
|
-
});
|
|
2088
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2089
|
-
...watch,
|
|
2090
|
-
...commitProgressWatchNudge(watch, { at, nudgeNumber: due.nudgeNumber }),
|
|
2091
|
-
});
|
|
2092
|
-
}
|
|
2093
|
-
catch {
|
|
2094
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2095
|
-
...watch,
|
|
2096
|
-
...deferProgressWatchNudge(watch, at),
|
|
2097
|
-
});
|
|
2098
|
-
}
|
|
2099
|
-
}
|
|
2100
|
-
}
|
|
2101
2068
|
async finishSettlements(settlements) {
|
|
2102
2069
|
await Promise.all(settlements.map(async (settlement) => this.finishSettlement(settlement)));
|
|
2103
2070
|
}
|
|
@@ -2140,13 +2107,13 @@ export class GatewayService {
|
|
|
2140
2107
|
const now = this.now().getTime();
|
|
2141
2108
|
const due = [
|
|
2142
2109
|
this.nextDiscoveryAt || now + DISCOVERY_INTERVAL_MS,
|
|
2143
|
-
this.
|
|
2110
|
+
this.nextManagedCodexCheckAt || now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS,
|
|
2144
2111
|
...(this.config.peerNodes.length > 0
|
|
2145
2112
|
? [this.nextPeerRefreshAt || now + PEER_REFRESH_INTERVAL_MS]
|
|
2146
2113
|
: []),
|
|
2114
|
+
...this.ephemeralPeerExpiries.values(),
|
|
2147
2115
|
...(deadlineAt === undefined ? [] : [Date.parse(deadlineAt)]),
|
|
2148
2116
|
...[...this.pendingClaudeReplies.values()].flatMap((rows) => rows.map((row) => Date.parse(row.deadlineAt))),
|
|
2149
|
-
...[...this.progressWatches.values()].map((watch) => Date.parse(watch.nextActionAt)),
|
|
2150
2117
|
].filter(Number.isFinite);
|
|
2151
2118
|
const delay = Math.max(0, Math.min(...due) - now);
|
|
2152
2119
|
this.wakeTimer = this.timers.setTimeout(() => {
|
|
@@ -2161,14 +2128,14 @@ export class GatewayService {
|
|
|
2161
2128
|
const now = this.now();
|
|
2162
2129
|
await this.finishSettlements(await this.store.expireDueMessages(now));
|
|
2163
2130
|
this.pruneExpiredPendingClaudeReplies(now.getTime());
|
|
2164
|
-
await this.
|
|
2131
|
+
await this.expireEphemeralPeers(now.getTime());
|
|
2165
2132
|
if (now.getTime() >= this.nextDiscoveryAt) {
|
|
2166
2133
|
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
2167
2134
|
this.nextDiscoveryAt = now.getTime() + DISCOVERY_INTERVAL_MS;
|
|
2168
2135
|
}
|
|
2169
|
-
if (now.getTime() >= this.
|
|
2170
|
-
await this.
|
|
2171
|
-
this.
|
|
2136
|
+
if (now.getTime() >= this.nextManagedCodexCheckAt) {
|
|
2137
|
+
await this.refreshManagedCodexSocket().catch(() => undefined);
|
|
2138
|
+
this.nextManagedCodexCheckAt = now.getTime() + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
|
|
2172
2139
|
}
|
|
2173
2140
|
if (this.nextPeerRefreshAt > 0 && now.getTime() >= this.nextPeerRefreshAt) {
|
|
2174
2141
|
const operation = this.refreshPeers();
|
|
@@ -2185,7 +2152,6 @@ export class GatewayService {
|
|
|
2185
2152
|
return;
|
|
2186
2153
|
for (const target of await this.store.inspectDispatchableTargets())
|
|
2187
2154
|
this.kick(target);
|
|
2188
|
-
await this.publish();
|
|
2189
2155
|
this.scheduleWake();
|
|
2190
2156
|
}
|
|
2191
2157
|
async refreshPeers() {
|
|
@@ -2249,18 +2215,23 @@ export class GatewayService {
|
|
|
2249
2215
|
if (viewChanged)
|
|
2250
2216
|
this.revision += 1;
|
|
2251
2217
|
}
|
|
2252
|
-
catch {
|
|
2218
|
+
catch (error) {
|
|
2253
2219
|
this.peerClients.get(peerHost)?.close();
|
|
2254
2220
|
this.peerClients.delete(peerHost);
|
|
2255
2221
|
if (!this.running || this.closing)
|
|
2256
2222
|
return;
|
|
2223
|
+
// A wire-version disagreement is not a tunnel fault: name it so an operator
|
|
2224
|
+
// upgrades the lagging node instead of debugging SSH.
|
|
2225
|
+
if (error instanceof PeerConnectionLostError && error.failureClass === "protocol")
|
|
2226
|
+
failureCode = "PEER_PROTOCOL_MISMATCH";
|
|
2257
2227
|
this.recordPeerFailure(peerHost, failureCode);
|
|
2258
2228
|
const observedAt = this.now().toISOString();
|
|
2229
|
+
const mirrorCode = failureCode === "PEER_PROTOCOL_MISMATCH" ? failureCode : "PEER_TUNNEL_UNAVAILABLE";
|
|
2259
2230
|
for (const route of await this.store.inspectPrivateRoutes())
|
|
2260
2231
|
if (route.registrationMode === "federated_peer" &&
|
|
2261
2232
|
route.binding.hostId === peerHost)
|
|
2262
2233
|
this.peerRouteViews.set(route.alias, { route: route.binding,
|
|
2263
|
-
state: "unobserved", observedAt, safeErrorCode:
|
|
2234
|
+
state: "unobserved", observedAt, safeErrorCode: mirrorCode });
|
|
2264
2235
|
}
|
|
2265
2236
|
}));
|
|
2266
2237
|
}
|
|
@@ -2323,34 +2294,19 @@ export class GatewayService {
|
|
|
2323
2294
|
for (const alias of retainedCollisions)
|
|
2324
2295
|
this.collidingClaudeAliases.add(alias);
|
|
2325
2296
|
}
|
|
2326
|
-
async
|
|
2327
|
-
if (this.
|
|
2297
|
+
async refreshManagedCodexSocket() {
|
|
2298
|
+
if (this.managedCodexSocketHeld === undefined)
|
|
2328
2299
|
return;
|
|
2329
|
-
this.
|
|
2300
|
+
this.managedCodexSocketHeldOutside = await this.managedCodexSocketHeld();
|
|
2330
2301
|
}
|
|
2331
2302
|
assertWritable() {
|
|
2332
2303
|
if (this.closing || !this.running) {
|
|
2333
2304
|
throw new BridgeError("GATEWAY_CLOSING", "The gateway is not accepting mutations.");
|
|
2334
2305
|
}
|
|
2335
2306
|
}
|
|
2336
|
-
async resolveClaudeSelector(selector, refresh) {
|
|
2337
|
-
if (refresh)
|
|
2338
|
-
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
2339
|
-
if (this.collidingClaudeAliases.has(selector))
|
|
2340
|
-
throw new BridgeError("PEER_ALIAS_COLLISION", "The Claude alias is ambiguous.");
|
|
2341
|
-
return [...this.candidates.values()].find((candidate) => candidate.routeHandle === selector ||
|
|
2342
|
-
candidate.alias === selector);
|
|
2343
|
-
}
|
|
2344
|
-
async resolveSelectedClaudeRoute(selector) {
|
|
2345
|
-
return (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
2346
|
-
(route.alias === selector || route.binding.routeHandle === selector));
|
|
2347
|
-
}
|
|
2348
2307
|
routeObservationStillCurrent(event) {
|
|
2349
2308
|
return this.connectors.has(connectorKey(event.route));
|
|
2350
2309
|
}
|
|
2351
|
-
async publish() {
|
|
2352
|
-
await this.publishDashboard(this.store.rootDir, await this.snapshot());
|
|
2353
|
-
}
|
|
2354
2310
|
alert(code, route, error) {
|
|
2355
2311
|
const errorCode = error instanceof BridgeError ? error.code : undefined;
|
|
2356
2312
|
this.runtimeAlerts.push({
|
|
@@ -2369,7 +2325,7 @@ export class GatewayService {
|
|
|
2369
2325
|
recordPeerFailure(host, code) {
|
|
2370
2326
|
for (let index = this.runtimeAlerts.length - 1; index >= 0; index -= 1) {
|
|
2371
2327
|
const alert = this.runtimeAlerts[index];
|
|
2372
|
-
if (alert?.host === host && (alert.code
|
|
2328
|
+
if (alert?.host === host && PEER_FAILURE_CODES.includes(alert.code))
|
|
2373
2329
|
this.runtimeAlerts.splice(index, 1);
|
|
2374
2330
|
}
|
|
2375
2331
|
if (code !== undefined)
|