agent-embassy 1.9.5 → 3.0.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 +87 -0
- package/CONTRIBUTING.md +19 -34
- package/README.md +110 -221
- package/SECURITY.md +55 -89
- 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 +9 -12
- package/dist/src/gateway/claude-helper-protocol.js +15 -13
- package/dist/src/gateway/claude-helper-protocol.js.map +1 -1
- package/dist/src/gateway/claude-helper-supervisor.d.ts +2 -6
- package/dist/src/gateway/claude-helper-supervisor.js +10 -9
- package/dist/src/gateway/claude-helper-supervisor.js.map +1 -1
- package/dist/src/gateway/claude-helper.js +18 -10
- 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 +6 -18
- package/dist/src/gateway/claude-peer.js.map +1 -1
- package/dist/src/gateway/cli.d.ts +18 -13
- package/dist/src/gateway/cli.js +942 -298
- package/dist/src/gateway/cli.js.map +1 -1
- package/dist/src/gateway/codex-local-transport.d.ts +1 -1
- package/dist/src/gateway/codex-local-transport.js +2 -2
- package/dist/src/gateway/codex-local-transport.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 +5 -13
- package/dist/src/gateway/config.js +7 -19
- package/dist/src/gateway/config.js.map +1 -1
- package/dist/src/gateway/control.d.ts +55 -108
- package/dist/src/gateway/control.js +95 -222
- package/dist/src/gateway/control.js.map +1 -1
- package/dist/src/gateway/federation-nodes.d.ts +29 -3
- package/dist/src/gateway/federation-nodes.js +200 -15
- 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 +8 -11
- package/dist/src/gateway/peer-protocol.js +5 -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 +10 -7
- package/dist/src/gateway/providers.js +21 -28
- package/dist/src/gateway/providers.js.map +1 -1
- package/dist/src/gateway/server.d.ts +4 -14
- package/dist/src/gateway/server.js +33 -59
- 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 +117 -32
- package/dist/src/gateway/service.js +525 -576
- package/dist/src/gateway/service.js.map +1 -1
- package/dist/src/gateway/status-view.d.ts +167 -0
- package/dist/src/gateway/status-view.js +488 -0
- package/dist/src/gateway/status-view.js.map +1 -0
- package/dist/src/gateway/store.d.ts +103 -21
- package/dist/src/gateway/store.js +473 -547
- package/dist/src/gateway/store.js.map +1 -1
- package/dist/src/gateway/types.d.ts +51 -102
- package/dist/src/gateway/types.js +15 -52
- package/dist/src/gateway/types.js.map +1 -1
- package/docs/CONFIGURATION.md +194 -59
- package/docs/DELIVERY.md +11 -11
- package/docs/GATEWAY-ARCHITECTURE.md +286 -394
- package/package.json +4 -12
- package/skills/embassy-peer/SKILL.md +72 -92
- package/skills/embassy-peer/agents/openai.yaml +1 -1
- package/README.zh-CN.md +0 -269
- 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/cli-copy.d.ts +0 -8
- package/dist/src/gateway/cli-copy.en.d.ts +0 -17
- package/dist/src/gateway/cli-copy.en.js +0 -58
- package/dist/src/gateway/cli-copy.en.js.map +0 -1
- package/dist/src/gateway/cli-copy.js +0 -22
- package/dist/src/gateway/cli-copy.js.map +0 -1
- package/dist/src/gateway/cli-copy.zh-CN.d.ts +0 -17
- package/dist/src/gateway/cli-copy.zh-CN.js +0 -58
- 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 -58
- 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/dist/src/gateway/state-v2-to-v3.d.ts +0 -24
- package/dist/src/gateway/state-v2-to-v3.js +0 -1034
- package/dist/src/gateway/state-v2-to-v3.js.map +0 -1
- package/docs/CONFIGURATION.zh-CN.md +0 -93
- 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,
|
|
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, spawnPeerClient } from "./peer-client.js";
|
|
5
|
+
import { 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, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, } from "./types.js";
|
|
7
|
+
import { CONNECTOR_OBSERVATION_STALE_AFTER_MS, directionId, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, 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;
|
|
@@ -76,9 +101,13 @@ function connectorKey(identity) {
|
|
|
76
101
|
return `${identity.provider}\0${identity.hostId}`;
|
|
77
102
|
}
|
|
78
103
|
function publicRegistry(value, previouslyParseable) {
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
.
|
|
104
|
+
const counts = new Map();
|
|
105
|
+
for (const row of value.rejected)
|
|
106
|
+
if (SAFE_CODE.test(row.safeErrorCode) && Number.isSafeInteger(row.count) && row.count > 0)
|
|
107
|
+
counts.set(row.safeErrorCode, (counts.get(row.safeErrorCode) ?? 0) + row.count);
|
|
108
|
+
const rejected = [...counts].map(([safeErrorCode, count]) => ({ safeErrorCode, count }))
|
|
109
|
+
.sort((left, right) => left.safeErrorCode < right.safeErrorCode ? -1 :
|
|
110
|
+
left.safeErrorCode > right.safeErrorCode ? 1 : 0);
|
|
82
111
|
const retained = rejected.slice(0, gatewayPublicSnapshotLimits.registryRejectionCodes);
|
|
83
112
|
return {
|
|
84
113
|
entriesScanned: Math.max(0, value.entriesScanned),
|
|
@@ -96,10 +125,7 @@ function bodyHash(body) {
|
|
|
96
125
|
return createHash("sha256").update(body, "utf8").digest("hex");
|
|
97
126
|
}
|
|
98
127
|
function peerCatalogAuthorityChanged(prior, current) {
|
|
99
|
-
const authority = (catalog) => JSON.stringify(
|
|
100
|
-
catalog.routes.map(({ state: _state, queueDepth: _depth, lastSeenAt: _seen, safeErrorCode: _code, ...route }) => route).sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
101
|
-
[...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
102
|
-
]);
|
|
128
|
+
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)));
|
|
103
129
|
return prior === undefined || authority(prior) !== authority(current);
|
|
104
130
|
}
|
|
105
131
|
function peerCatalogViewChanged(prior, current) {
|
|
@@ -107,7 +133,6 @@ function peerCatalogViewChanged(prior, current) {
|
|
|
107
133
|
catalog.health,
|
|
108
134
|
catalog.connectors.map(({ lastSeenAt: _seen, observationAgeMs: _age, ...row }) => row),
|
|
109
135
|
catalog.routes.map(({ lastSeenAt: _seen, ...row }) => row).sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
110
|
-
[...catalog.consentEdges].sort((a, b) => a.ref.localeCompare(b.ref)),
|
|
111
136
|
catalog.alerts.map(({ timestamp: _timestamp, ...row }) => row),
|
|
112
137
|
]);
|
|
113
138
|
return prior === undefined || view(prior) !== view(current);
|
|
@@ -119,11 +144,10 @@ export class GatewayService {
|
|
|
119
144
|
config;
|
|
120
145
|
store;
|
|
121
146
|
adapters;
|
|
122
|
-
publishDashboard;
|
|
123
147
|
now;
|
|
124
148
|
timers;
|
|
125
149
|
nativePeerCwd;
|
|
126
|
-
|
|
150
|
+
managedCodexSocketHeld;
|
|
127
151
|
spawnPeer;
|
|
128
152
|
peerClients = new Map();
|
|
129
153
|
peerCatalogs = new Map();
|
|
@@ -133,6 +157,7 @@ export class GatewayService {
|
|
|
133
157
|
connectors = new Map();
|
|
134
158
|
routeObservations = new Map();
|
|
135
159
|
candidates = new Map();
|
|
160
|
+
collidingClaudeAliases = new Set();
|
|
136
161
|
conversations = new Map();
|
|
137
162
|
messageContexts = new Map();
|
|
138
163
|
activeAttempts = new Map();
|
|
@@ -141,18 +166,25 @@ export class GatewayService {
|
|
|
141
166
|
peerRefreshOperations = new Set();
|
|
142
167
|
pendingClaudeReplies = new Map();
|
|
143
168
|
nativeReceipts = new Map();
|
|
144
|
-
progressWatches = new Map();
|
|
145
|
-
progressWatchEvents = [];
|
|
146
169
|
dispatchRunners = new Map();
|
|
147
170
|
steerRunners = new Map();
|
|
148
171
|
startingTargets = new Set();
|
|
149
172
|
runtimeAlerts = [];
|
|
173
|
+
/**
|
|
174
|
+
* Ephemeral peer registrations and the wall-clock instant each one dies.
|
|
175
|
+
* Nothing durable records them, so this map is the whole lifetime: it is
|
|
176
|
+
* swept on the ordinary maintenance tick, and a restart simply never sees
|
|
177
|
+
* the routes at all.
|
|
178
|
+
*/
|
|
179
|
+
ephemeralPeerExpiries = new Map();
|
|
180
|
+
/** Consecutive failed retirements per ephemeral alias; cleared on success or removal. */
|
|
181
|
+
ephemeralExpiryFailures = new Map();
|
|
150
182
|
control;
|
|
151
183
|
wakeTimer;
|
|
152
184
|
nextDiscoveryAt = 0;
|
|
153
|
-
|
|
185
|
+
nextManagedCodexCheckAt = 0;
|
|
154
186
|
nextPeerRefreshAt = 0;
|
|
155
|
-
|
|
187
|
+
managedCodexSocketHeldOutside = false;
|
|
156
188
|
revision = 0;
|
|
157
189
|
snapshotRevision = 0;
|
|
158
190
|
snapshotFingerprint;
|
|
@@ -163,10 +195,9 @@ export class GatewayService {
|
|
|
163
195
|
this.config = options.config;
|
|
164
196
|
this.adapters = [...(options.adapters ?? [])];
|
|
165
197
|
this.store = options.store ?? new GatewayStore(options.config, options.now === undefined ? {} : { now: options.now });
|
|
166
|
-
this.publishDashboard = options.publishDashboard ?? publishGatewayDashboard;
|
|
167
198
|
this.now = options.now ?? (() => new Date());
|
|
168
199
|
this.nativePeerCwd = options.nativePeerCwd ?? process.cwd();
|
|
169
|
-
this.
|
|
200
|
+
this.managedCodexSocketHeld = options.managedCodexSocketHeld;
|
|
170
201
|
this.spawnPeer = options.spawnPeer ?? spawnPeerClient;
|
|
171
202
|
this.timers = options.timers ?? {
|
|
172
203
|
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
@@ -211,7 +242,7 @@ export class GatewayService {
|
|
|
211
242
|
}
|
|
212
243
|
}
|
|
213
244
|
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
214
|
-
await this.
|
|
245
|
+
await this.refreshManagedCodexSocket().catch(() => undefined);
|
|
215
246
|
const control = await startGatewayControlServer({
|
|
216
247
|
stateDir: this.store.rootDir,
|
|
217
248
|
socketPath: this.config.controlSocketPath,
|
|
@@ -230,11 +261,10 @@ export class GatewayService {
|
|
|
230
261
|
await this.observeLoadedRoutes();
|
|
231
262
|
const now = this.now().getTime();
|
|
232
263
|
this.nextDiscoveryAt = now + DISCOVERY_INTERVAL_MS;
|
|
233
|
-
this.
|
|
234
|
-
this.nextPeerRefreshAt =
|
|
264
|
+
this.nextManagedCodexCheckAt = now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
|
|
265
|
+
this.nextPeerRefreshAt = this.config.peerNodes.length > 0 ? now : 0;
|
|
235
266
|
for (const target of await this.store.inspectDispatchableTargets())
|
|
236
267
|
this.kick(target);
|
|
237
|
-
await this.publish();
|
|
238
268
|
this.scheduleWake();
|
|
239
269
|
assertActive();
|
|
240
270
|
}
|
|
@@ -307,7 +337,6 @@ export class GatewayService {
|
|
|
307
337
|
this.assertWritable();
|
|
308
338
|
await operation();
|
|
309
339
|
this.revision += 1;
|
|
310
|
-
await this.publish();
|
|
311
340
|
return { accepted: true, code: "ok" };
|
|
312
341
|
}
|
|
313
342
|
catch (error) {
|
|
@@ -322,7 +351,6 @@ export class GatewayService {
|
|
|
322
351
|
this.assertWritable();
|
|
323
352
|
const token = await this.registerPeer(params);
|
|
324
353
|
this.revision += 1;
|
|
325
|
-
await this.publish();
|
|
326
354
|
return token === undefined ? { accepted: true, code: "ok" } : { accepted: true, code: "ok", token };
|
|
327
355
|
}
|
|
328
356
|
catch (error) {
|
|
@@ -333,25 +361,24 @@ export class GatewayService {
|
|
|
333
361
|
awaitPeer: (params) => this.awaitPeer(params),
|
|
334
362
|
peerReceipt: (params) => decide(async () => this.peerReceipt(params), true),
|
|
335
363
|
unregisterCodex: (params) => decide(async () => this.unregisterCodex(params)),
|
|
336
|
-
removeCodexRegistration: (params) => decide(async () => this.removeCodexRegistration(params.alias)),
|
|
337
|
-
selectClaude: (params) => decide(async () => this.selectClaude(params)),
|
|
338
|
-
unselectClaude: (params) => decide(async () => this.unselectClaude(params)),
|
|
339
|
-
pair: (params) => decide(async () => this.pair(params)),
|
|
340
|
-
unpair: (params) => decide(async () => this.unpair(params)),
|
|
341
364
|
listSnapshot: () => this.snapshot(),
|
|
342
365
|
observeSnapshot: () => this.observeSnapshot(),
|
|
343
366
|
deliveryStatus: (params) => this.deliveryStatus(params.token),
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
sendToCodex: (params) => this.sendToCodex(params),
|
|
347
|
-
reply: (params) => this.reply(params),
|
|
348
|
-
refreshDashboard: async () => {
|
|
367
|
+
send: (params) => this.send(params),
|
|
368
|
+
refreshDiscovery: async () => {
|
|
349
369
|
if (this.closing) {
|
|
350
370
|
return { accepted: false, code: "unavailable", revision: this.revision };
|
|
351
371
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
372
|
+
try {
|
|
373
|
+
// An operator-triggered rescan reports its own failure. It must never
|
|
374
|
+
// journal `discovery_refreshed` for a scan that did not complete, and
|
|
375
|
+
// must never answer `accepted: true` for a provider that is down.
|
|
376
|
+
await this.refreshClaudeDiscovery();
|
|
377
|
+
await this.recordActivity("discovery", "discovery_refreshed", [], true);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
return { accepted: false, code: decisionFor(error).code, revision: this.revision };
|
|
381
|
+
}
|
|
355
382
|
return { accepted: true, code: "ok", revision: this.revision };
|
|
356
383
|
},
|
|
357
384
|
peerCatalog: ({ peerHost }) => this.buildPeerCatalog(peerHost),
|
|
@@ -359,15 +386,18 @@ export class GatewayService {
|
|
|
359
386
|
};
|
|
360
387
|
}
|
|
361
388
|
async buildPeerCatalog(peerHost) {
|
|
362
|
-
const localHost = this.config.hostId
|
|
363
|
-
if (!
|
|
389
|
+
const localHost = this.config.hostId;
|
|
390
|
+
if (!this.config.peerNodes.includes(peerHost))
|
|
364
391
|
throw new BridgeError("PEER_NOT_CONFIGURED", "The requested peer host is not configured.");
|
|
365
|
-
const [snapshot, privateRoutes
|
|
366
|
-
this.snapshot(), this.store.inspectPrivateRoutes(),
|
|
392
|
+
const [snapshot, privateRoutes] = await Promise.all([
|
|
393
|
+
this.snapshot(), this.store.inspectPrivateRoutes(),
|
|
367
394
|
]);
|
|
368
|
-
|
|
395
|
+
// An ephemeral registration is never published: a peer node that mirrored
|
|
396
|
+
// one would hold a route this broker will delete on a timer and can never
|
|
397
|
+
// restore, and a self-test must not leave a trace on another machine.
|
|
398
|
+
const localRoutes = privateRoutes.filter((route) => route.registrationMode !== "federated_peer" &&
|
|
399
|
+
route.binding.hostId === localHost && !this.store.isEphemeralRegistration(route.binding.registrationId));
|
|
369
400
|
const publicRoutes = new Map(snapshot.routes.map((route) => [route.alias, route]));
|
|
370
|
-
const routeByAlias = new Map(privateRoutes.map((route) => [route.alias, route]));
|
|
371
401
|
const routes = localRoutes.map((route) => {
|
|
372
402
|
const row = publicRoutes.get(route.alias);
|
|
373
403
|
return {
|
|
@@ -377,30 +407,16 @@ export class GatewayService {
|
|
|
377
407
|
...(row?.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
|
|
378
408
|
};
|
|
379
409
|
});
|
|
380
|
-
const consentEdges = privateEdges.flatMap((edge) => {
|
|
381
|
-
if (edge.endpoints.map((endpoint) => aliasHost(endpoint.alias)).sort()[0] !== localHost)
|
|
382
|
-
return [];
|
|
383
|
-
const rows = edge.endpoints.map((endpoint) => routeByAlias.get(endpoint.alias));
|
|
384
|
-
if (rows[0] === undefined || rows[1] === undefined ||
|
|
385
|
-
!rows.some((route) => route?.binding.hostId === peerHost) ||
|
|
386
|
-
!rows.some((route) => route?.binding.hostId === localHost))
|
|
387
|
-
return [];
|
|
388
|
-
const endpoint = (route) => ({ alias: route.alias, provider: route.binding.provider,
|
|
389
|
-
host: route.binding.hostId, routeRef: route.registrationMode === "federated_peer"
|
|
390
|
-
? route.binding.routeHandle : peerRouteRef(localHost, route.binding.registrationId) });
|
|
391
|
-
const endpoints = [endpoint(rows[0]), endpoint(rows[1])];
|
|
392
|
-
return [{ ref: peerEdgeRef(endpoints), ownerHost: localHost, endpoints }];
|
|
393
|
-
});
|
|
394
410
|
return { revision: this.revision, complete: true, truncated: false, generatedAt: snapshot.generatedAt,
|
|
395
411
|
health: snapshot.health, connectors: snapshot.connectors.filter((row) => row.host === localHost).map((row) => ({
|
|
396
412
|
provider: row.provider, host: row.host, health: row.health, protocol: row.protocol, protocolVersion: row.protocolVersion,
|
|
397
413
|
...(row.lastSeenAt === undefined ? {} : { lastSeenAt: row.lastSeenAt }),
|
|
398
414
|
...(row.observationAgeMs === undefined ? {} : { observationAgeMs: row.observationAgeMs }),
|
|
399
415
|
...(row.safeErrorCode === undefined ? {} : { safeErrorCode: row.safeErrorCode }),
|
|
400
|
-
})), routes,
|
|
416
|
+
})), routes, alerts: snapshot.alerts.filter((alert) => (alert.host === undefined || alert.host === localHost) && (alert.alias === undefined || aliasHost(alert.alias) === localHost)), };
|
|
401
417
|
}
|
|
402
418
|
async receivePeerHandoff(peerHost, handoff) {
|
|
403
|
-
if (!
|
|
419
|
+
if (!this.config.peerNodes.includes(peerHost))
|
|
404
420
|
throw new BridgeError("PEER_NOT_CONFIGURED", "The sending peer host is not configured.");
|
|
405
421
|
const enqueued = await this.store.enqueuePeerHandoff(peerHost, handoff);
|
|
406
422
|
if (enqueued.messageId !== undefined) {
|
|
@@ -411,12 +427,11 @@ export class GatewayService {
|
|
|
411
427
|
const conversationId = conversationIdForSuffix(handoff.conversationCorrelation);
|
|
412
428
|
this.rememberConversation({ id: conversationId, sourceAlias: source.alias, targetAlias: target.alias,
|
|
413
429
|
sourceBinding: source.binding, targetBinding: target.binding, expectsReply: handoff.expectsReply,
|
|
414
|
-
|
|
430
|
+
nextSequence: 1 });
|
|
415
431
|
this.messageContexts.set(enqueued.messageId, { conversationId, expectsReply: handoff.expectsReply });
|
|
416
432
|
this.kick(target.alias, target.binding.registrationId);
|
|
417
433
|
}
|
|
418
434
|
this.revision += 1;
|
|
419
|
-
await this.publish();
|
|
420
435
|
return { accepted: true };
|
|
421
436
|
}
|
|
422
437
|
async snapshot() {
|
|
@@ -450,30 +465,23 @@ export class GatewayService {
|
|
|
450
465
|
const age = observedAt === undefined ? undefined : Math.max(0, now.getTime() - Date.parse(observedAt));
|
|
451
466
|
const stale = runtime.health === "healthy" &&
|
|
452
467
|
(age === undefined || age > CONNECTOR_OBSERVATION_STALE_AFTER_MS);
|
|
453
|
-
const
|
|
454
|
-
const doctorConditions = doctor === undefined
|
|
455
|
-
? [] : [...new Set(doctor.conditions)];
|
|
456
|
-
if (doctor !== undefined && stale && !doctorConditions.includes("observation_stale"))
|
|
457
|
-
doctorConditions.push("observation_stale");
|
|
458
|
-
const managedLayoutMissing = doctorConditions.includes("managed_layout_missing");
|
|
468
|
+
const socketHeld = runtime.adapter.identity.provider === "codex" && this.managedCodexSocketHeldOutside;
|
|
459
469
|
const connectorCode = stale ? "CONNECTOR_OBSERVATION_STALE" : runtime.safeErrorCode ??
|
|
460
|
-
(
|
|
470
|
+
(socketHeld ? "MANAGED_CODEX_UNAVAILABLE" : undefined);
|
|
461
471
|
return {
|
|
462
472
|
provider: runtime.adapter.identity.provider,
|
|
463
473
|
host: runtime.adapter.identity.hostId,
|
|
464
|
-
health: stale ||
|
|
474
|
+
health: stale || socketHeld ? "degraded" : runtime.health,
|
|
465
475
|
protocol: runtime.adapter.protocol,
|
|
466
476
|
protocolVersion: runtime.adapter.protocolVersion,
|
|
467
477
|
...(observedAt === undefined ? {} : { lastSeenAt: observedAt }),
|
|
468
478
|
...(age === undefined ? {} : { observationAgeMs: age }),
|
|
469
|
-
...(doctorConditions.length === 0 ? {} : {
|
|
470
|
-
codexDoctor: { conditions: doctorConditions.slice(0, 2) },
|
|
471
|
-
}),
|
|
472
479
|
...(runtime.registry === undefined ? {} : { registry: runtime.registry }),
|
|
473
480
|
...(connectorCode === undefined ? {} : { safeErrorCode: connectorCode }),
|
|
474
481
|
};
|
|
475
482
|
});
|
|
476
483
|
const availablePeers = [...this.candidates.values()]
|
|
484
|
+
.filter((candidate) => !this.collidingClaudeAliases.has(candidate.alias))
|
|
477
485
|
.slice(0, gatewayPublicSnapshotLimits.availablePeers)
|
|
478
486
|
.map((candidate) => ({
|
|
479
487
|
alias: candidate.alias,
|
|
@@ -481,20 +489,9 @@ export class GatewayService {
|
|
|
481
489
|
host: candidate.adapter.identity.hostId,
|
|
482
490
|
state: candidate.state,
|
|
483
491
|
validated: true,
|
|
484
|
-
|
|
492
|
+
routed: routes.some((route) => route.provider === "claude" && route.alias === candidate.alias),
|
|
485
493
|
lastSeenAt: candidate.observedAt,
|
|
486
494
|
}));
|
|
487
|
-
const progressWatches = [...this.progressWatches.values()]
|
|
488
|
-
.slice(0, gatewayPublicSnapshotLimits.progressWatches)
|
|
489
|
-
.map((watch) => ({
|
|
490
|
-
conversationIdSuffix: watch.conversationId.slice(-8),
|
|
491
|
-
ownerAlias: watch.ownerAlias,
|
|
492
|
-
workerAlias: watch.workerAlias,
|
|
493
|
-
lastActivityAt: watch.lastActivityAt,
|
|
494
|
-
nextActionAt: watch.nextActionAt,
|
|
495
|
-
nudgeCount: watch.nudgeCount,
|
|
496
|
-
}));
|
|
497
|
-
const progressWatchEvents = this.progressWatchEvents.slice(-gatewayPublicSnapshotLimits.progressWatchEvents);
|
|
498
495
|
return projectGatewayPublicSnapshot({
|
|
499
496
|
...base,
|
|
500
497
|
generatedAt: now.toISOString(),
|
|
@@ -506,14 +503,8 @@ export class GatewayService {
|
|
|
506
503
|
connectors,
|
|
507
504
|
availablePeers,
|
|
508
505
|
routes,
|
|
509
|
-
progressWatches,
|
|
510
|
-
progressWatchEvents,
|
|
511
506
|
alerts: [...base.alerts, ...this.runtimeAlerts].slice(-gatewayPublicSnapshotLimits.alerts),
|
|
512
|
-
truncation:
|
|
513
|
-
...base.truncation,
|
|
514
|
-
progressWatches: Math.max(0, this.progressWatches.size - progressWatches.length),
|
|
515
|
-
progressWatchEvents: Math.max(0, this.progressWatchEvents.length - progressWatchEvents.length),
|
|
516
|
-
},
|
|
507
|
+
truncation: base.truncation,
|
|
517
508
|
}, GATEWAY_PUBLIC_SNAPSHOT_BYTE_BUDGET);
|
|
518
509
|
}
|
|
519
510
|
async observeSnapshot() {
|
|
@@ -594,7 +585,7 @@ export class GatewayService {
|
|
|
594
585
|
}
|
|
595
586
|
async advertise(route) {
|
|
596
587
|
const hostId = route.registrationMode === "federated_peer"
|
|
597
|
-
?
|
|
588
|
+
? this.config.hostId : route.binding.hostId;
|
|
598
589
|
await this.claudeAdapter(hostId)?.advertiseNativeSourcePeer?.({
|
|
599
590
|
alias: route.alias,
|
|
600
591
|
sourceProvider: route.binding.provider,
|
|
@@ -603,7 +594,7 @@ export class GatewayService {
|
|
|
603
594
|
}
|
|
604
595
|
async unadvertise(route) {
|
|
605
596
|
const hostId = route.registrationMode === "federated_peer"
|
|
606
|
-
?
|
|
597
|
+
? this.config.hostId : route.binding.hostId;
|
|
607
598
|
await this.claudeAdapter(hostId)?.unadvertiseNativeSourcePeer?.(route.alias);
|
|
608
599
|
}
|
|
609
600
|
reconcileAdvertisement(route) {
|
|
@@ -612,7 +603,7 @@ export class GatewayService {
|
|
|
612
603
|
async reconcileUnadvertisement(route) {
|
|
613
604
|
await this.unadvertise(route).catch((error) => this.alert("NATIVE_UNADVERTISEMENT_FAILED", route, error));
|
|
614
605
|
}
|
|
615
|
-
async removeOwnedRoute(route
|
|
606
|
+
async removeOwnedRoute(route) {
|
|
616
607
|
this.assertWritable();
|
|
617
608
|
const result = await this.store.removeOwnedRouteAtomic({
|
|
618
609
|
alias: route.alias,
|
|
@@ -620,16 +611,10 @@ export class GatewayService {
|
|
|
620
611
|
activity: { operatorAction: true },
|
|
621
612
|
});
|
|
622
613
|
if (!result.removed)
|
|
623
|
-
throw new BridgeError(
|
|
614
|
+
throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact route is absent.");
|
|
624
615
|
await this.finishSettlements(result.settlements);
|
|
625
|
-
this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
|
|
626
616
|
this.forgetRoute(route);
|
|
627
|
-
|
|
628
|
-
await this.adapterFor(route.binding)?.releaseRoute?.(route.binding.routeHandle);
|
|
629
|
-
}
|
|
630
|
-
else {
|
|
631
|
-
await this.reconcileUnadvertisement(route);
|
|
632
|
-
}
|
|
617
|
+
await this.reconcileUnadvertisement(route);
|
|
633
618
|
}
|
|
634
619
|
async recordActivity(kind, action, aliases, operatorAction) {
|
|
635
620
|
await this.store.recordActivity({
|
|
@@ -690,7 +675,6 @@ export class GatewayService {
|
|
|
690
675
|
activity: { operatorAction: true },
|
|
691
676
|
});
|
|
692
677
|
await this.finishSettlements(result.settlements);
|
|
693
|
-
this.settleWatchesForAlias(previous.alias, "endpoint_retired", "operator");
|
|
694
678
|
if (!result.idempotent) {
|
|
695
679
|
this.forgetRoute(previous);
|
|
696
680
|
this.reconcileUnadvertisement(previous);
|
|
@@ -714,15 +698,20 @@ export class GatewayService {
|
|
|
714
698
|
this.kick(existing.alias, existing.binding.registrationId);
|
|
715
699
|
return undefined;
|
|
716
700
|
}
|
|
717
|
-
if (params.token !== undefined || !params.alias.endsWith(`@${this.config.hostId
|
|
701
|
+
if (params.token !== undefined || !params.alias.endsWith(`@${this.config.hostId}`)) {
|
|
718
702
|
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
719
703
|
}
|
|
720
704
|
const token = `peer_${randomBytes(24).toString("base64url")}`;
|
|
721
705
|
await this.store.registerRoute({ alias: params.alias, binding: {
|
|
722
|
-
provider: "peer", hostId: this.config.hostId
|
|
706
|
+
provider: "peer", hostId: this.config.hostId,
|
|
723
707
|
routeHandle: peerHandle(process.getuid(), params.alias, token), registrationId: registrationId(),
|
|
724
|
-
}, registrationMode: "explicit_opt_in"
|
|
708
|
+
}, registrationMode: "explicit_opt_in",
|
|
709
|
+
...(params.ephemeral === true ? { ephemeral: true } : {}) });
|
|
725
710
|
const installed = (await this.store.inspectPrivateRoute(params.alias));
|
|
711
|
+
if (params.ephemeral === true) {
|
|
712
|
+
this.ephemeralPeerExpiries.set(installed.alias, this.now().getTime() + (params.ttlMs ?? GATEWAY_EPHEMERAL_PEER_TTL_DEFAULT_MS));
|
|
713
|
+
this.scheduleWake();
|
|
714
|
+
}
|
|
726
715
|
this.observeRoute(installed);
|
|
727
716
|
this.reconcileAdvertisement(installed);
|
|
728
717
|
return token;
|
|
@@ -743,19 +732,86 @@ export class GatewayService {
|
|
|
743
732
|
if (this.peerCleanupAliases.has(route.alias))
|
|
744
733
|
throw new BridgeError("ROUTE_BUSY", "The route is still completing cleanup.", true);
|
|
745
734
|
this.peerCleanupAliases.add(route.alias);
|
|
735
|
+
// The expiry leaves the map before the removal, under the same cleanup
|
|
736
|
+
// guard, so a clock that fires mid-removal has nothing to act on; a
|
|
737
|
+
// removal that does not happen puts it back, so a refused unregister
|
|
738
|
+
// still leaves the registration to its own expiry.
|
|
739
|
+
const expiry = this.ephemeralPeerExpiries.get(route.alias);
|
|
740
|
+
this.ephemeralPeerExpiries.delete(route.alias);
|
|
741
|
+
this.ephemeralExpiryFailures.delete(route.alias);
|
|
742
|
+
let removed = false;
|
|
746
743
|
try {
|
|
747
744
|
const result = await this.store.removeOwnedRouteAtomic({ alias: route.alias, binding: route.binding });
|
|
748
745
|
if (!result.removed)
|
|
749
746
|
throw new BridgeError("ROUTE_UNREGISTERED", "The route binding does not match.");
|
|
747
|
+
removed = true;
|
|
750
748
|
await this.finishSettlements(result.settlements);
|
|
751
|
-
this.settleWatchesForAlias(route.alias, "endpoint_retired", "operator");
|
|
752
749
|
this.forgetRoute(route);
|
|
753
750
|
await this.reconcileUnadvertisement(route);
|
|
754
751
|
}
|
|
755
752
|
finally {
|
|
753
|
+
if (!removed && expiry !== undefined)
|
|
754
|
+
this.ephemeralPeerExpiries.set(route.alias, expiry);
|
|
756
755
|
this.peerCleanupAliases.delete(route.alias);
|
|
757
756
|
}
|
|
758
757
|
}
|
|
758
|
+
/**
|
|
759
|
+
* Retires every ephemeral peer registration whose lifetime has run out. It
|
|
760
|
+
* is the same removal an explicit `unregister-peer` performs — queued work
|
|
761
|
+
* is settled, not abandoned — minus the token check, because the authority
|
|
762
|
+
* here is the clock the broker set itself.
|
|
763
|
+
*
|
|
764
|
+
* The clock is keyed by alias, and an alias can be re-registered durably
|
|
765
|
+
* after its ephemeral holder left, so nothing is retired unless the route
|
|
766
|
+
* behind the alias is still an ephemeral one. A retirement that throws
|
|
767
|
+
* keeps its entry and is retried `EPHEMERAL_EXPIRY_RETRY_MS` later, with an
|
|
768
|
+
* alert each time; after `EPHEMERAL_EXPIRY_MAX_ATTEMPTS` consecutive
|
|
769
|
+
* failures the entry is dropped and the registration is left to the next
|
|
770
|
+
* restart, which clears it by construction.
|
|
771
|
+
*/
|
|
772
|
+
async expireEphemeralPeers(now) {
|
|
773
|
+
for (const [alias, expiresAt] of [...this.ephemeralPeerExpiries]) {
|
|
774
|
+
if (expiresAt > now)
|
|
775
|
+
continue;
|
|
776
|
+
// Another owner is mid-removal; the entry is theirs to clear or restore.
|
|
777
|
+
if (this.peerCleanupAliases.has(alias))
|
|
778
|
+
continue;
|
|
779
|
+
const route = await this.store.inspectPrivateRoute(alias);
|
|
780
|
+
if (route === undefined || !this.store.isEphemeralRegistration(route.binding.registrationId)) {
|
|
781
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
782
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
this.peerCleanupAliases.add(alias);
|
|
786
|
+
try {
|
|
787
|
+
const result = await this.store.removeOwnedRouteAtomic({ alias, binding: route.binding });
|
|
788
|
+
if (result.removed) {
|
|
789
|
+
await this.finishSettlements(result.settlements);
|
|
790
|
+
this.forgetRoute(route);
|
|
791
|
+
await this.reconcileUnadvertisement(route);
|
|
792
|
+
}
|
|
793
|
+
// Not removed means the binding changed underneath: the alias belongs
|
|
794
|
+
// to someone else now, and this clock is spent either way.
|
|
795
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
796
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
797
|
+
}
|
|
798
|
+
catch (error) {
|
|
799
|
+
this.alert("EPHEMERAL_ROUTE_EXPIRY_FAILED", route, error);
|
|
800
|
+
const attempts = (this.ephemeralExpiryFailures.get(alias) ?? 0) + 1;
|
|
801
|
+
if (attempts >= EPHEMERAL_EXPIRY_MAX_ATTEMPTS) {
|
|
802
|
+
this.ephemeralPeerExpiries.delete(alias);
|
|
803
|
+
this.ephemeralExpiryFailures.delete(alias);
|
|
804
|
+
}
|
|
805
|
+
else {
|
|
806
|
+
this.ephemeralExpiryFailures.set(alias, attempts);
|
|
807
|
+
this.ephemeralPeerExpiries.set(alias, now + EPHEMERAL_EXPIRY_RETRY_MS);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
finally {
|
|
811
|
+
this.peerCleanupAliases.delete(alias);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
}
|
|
759
815
|
async awaitPeer(params) {
|
|
760
816
|
this.assertWritable();
|
|
761
817
|
const route = await this.assertPeer(params);
|
|
@@ -780,33 +836,56 @@ export class GatewayService {
|
|
|
780
836
|
route.binding.routeHandle !== params.threadId) {
|
|
781
837
|
throw new BridgeError("CODEX_REGISTRATION_NOT_FOUND", "The exact Codex registration is absent.");
|
|
782
838
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
const
|
|
839
|
+
if (route.registrationMode === "federated_peer") {
|
|
840
|
+
throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node and cannot be unregistered here.");
|
|
841
|
+
}
|
|
842
|
+
await this.removeOwnedRoute(route);
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* The name this session may take. A name belongs to one route at a time, so
|
|
846
|
+
* when another Claude route already holds it and THAT route's session is
|
|
847
|
+
* still live, this session takes a disambiguated name rather than displacing
|
|
848
|
+
* a working session and cancelling its queued work. Twins that share a
|
|
849
|
+
* display name therefore both keep routes and both stay reachable by UUID;
|
|
850
|
+
* only a route whose session is actually gone is displaced.
|
|
851
|
+
*/
|
|
852
|
+
claimableAlias(candidate, routes) {
|
|
853
|
+
const holder = routes.find((route) => route.alias === candidate.alias &&
|
|
854
|
+
route.binding.provider === "claude" &&
|
|
855
|
+
!(route.binding.hostId === candidate.adapter.identity.hostId &&
|
|
856
|
+
route.binding.routeHandle === candidate.routeHandle));
|
|
857
|
+
if (holder === undefined)
|
|
858
|
+
return candidate.alias;
|
|
859
|
+
const holderIsLive = [...this.candidates.values()].some((row) => row.adapter.identity.hostId === holder.binding.hostId &&
|
|
860
|
+
row.routeHandle === holder.binding.routeHandle);
|
|
861
|
+
return holderIsLive
|
|
862
|
+
? disambiguatedAlias(candidate.alias, candidate.routeHandle)
|
|
863
|
+
: candidate.alias;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Installs (or brings up to date) the route of one discovered Claude
|
|
867
|
+
* session. The adapter pins the session's route handle, the workspace
|
|
868
|
+
* check keeps deliberately broad workspaces out, and the store reuses the
|
|
869
|
+
* registration of a session already bound under the same (host, UUID) so a
|
|
870
|
+
* re-anchored or renamed session keeps its identity and in-flight
|
|
871
|
+
* conversations. Any Claude route the install displaced is forgotten and
|
|
872
|
+
* released at the adapter; its work was settled `ENDPOINT_RETIRED` by the
|
|
873
|
+
* store — and only a route whose own session is gone is ever displaced.
|
|
874
|
+
*/
|
|
875
|
+
async installClaudeRoute(candidate) {
|
|
876
|
+
const adapter = candidate.adapter;
|
|
798
877
|
if (adapter.selectRoute === undefined) {
|
|
799
|
-
throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot
|
|
878
|
+
throw new BridgeError("CLAUDE_PROVIDER_UNAVAILABLE", "The Claude provider cannot install routes.");
|
|
800
879
|
}
|
|
801
|
-
await adapter.assertWorkspaceDisjoint?.(
|
|
802
|
-
const chosen = await adapter.selectRoute({ alias:
|
|
880
|
+
await adapter.assertWorkspaceDisjoint?.(candidate.routeHandle, this.store.rootDir);
|
|
881
|
+
const chosen = await adapter.selectRoute({ alias: candidate.alias, routeHandle: candidate.routeHandle });
|
|
803
882
|
this.assertWritable();
|
|
804
883
|
const before = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
|
|
805
|
-
const
|
|
806
|
-
|
|
884
|
+
const alias = this.claimableAlias(candidate, before);
|
|
885
|
+
const existing = before.find((route) => route.binding.hostId === adapter.identity.hostId &&
|
|
807
886
|
route.binding.routeHandle === chosen.routeHandle);
|
|
808
|
-
const
|
|
809
|
-
alias
|
|
887
|
+
const input = {
|
|
888
|
+
alias,
|
|
810
889
|
binding: {
|
|
811
890
|
provider: "claude",
|
|
812
891
|
hostId: adapter.identity.hostId,
|
|
@@ -814,112 +893,196 @@ export class GatewayService {
|
|
|
814
893
|
registrationId: existing?.binding.registrationId ?? registrationId(),
|
|
815
894
|
},
|
|
816
895
|
registrationMode: "selected_live_peer",
|
|
817
|
-
}
|
|
896
|
+
};
|
|
897
|
+
const result = await this.installWithConcurrentFirstSend(input, adapter.identity.hostId, chosen.routeHandle);
|
|
818
898
|
await this.finishSettlements(result.settlements);
|
|
819
899
|
const after = (await this.store.listLogicalRoutes()).filter((route) => route.binding.provider === "claude");
|
|
820
900
|
for (const prior of before) {
|
|
821
|
-
const retained = after.find((route) => route.
|
|
822
|
-
if (retained === undefined
|
|
823
|
-
|
|
901
|
+
const retained = after.find((route) => route.binding.registrationId === prior.binding.registrationId);
|
|
902
|
+
if (retained === undefined) {
|
|
903
|
+
// Displaced: the alias now names another session.
|
|
824
904
|
this.forgetRoute(prior);
|
|
825
905
|
void this.adapterFor(prior.binding)?.releaseRoute?.(prior.binding.routeHandle)
|
|
826
906
|
.catch((error) => this.alert("ROUTE_RELEASE_FAILED", prior, error));
|
|
827
907
|
}
|
|
908
|
+
else if (retained.alias !== prior.alias) {
|
|
909
|
+
// Renamed: identity kept, the stale display-only observation goes and
|
|
910
|
+
// open conversations follow the session to its new name.
|
|
911
|
+
this.routeObservations.delete(prior.alias);
|
|
912
|
+
this.renameConversationCoordinates(prior.alias, retained.alias, prior.binding.registrationId);
|
|
913
|
+
}
|
|
828
914
|
}
|
|
829
|
-
const installed = (await this.store.inspectPrivateRoute(
|
|
915
|
+
const installed = (await this.store.inspectPrivateRoute(alias));
|
|
830
916
|
this.observeRoute(installed);
|
|
831
|
-
this.routeObservations.set(
|
|
917
|
+
this.routeObservations.set(alias, {
|
|
832
918
|
route: installed.binding,
|
|
833
919
|
state: chosen.state,
|
|
834
920
|
observedAt: this.now().toISOString(),
|
|
835
921
|
});
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
if ((this.config.hostId ?? this.config.allowedHosts[0]) !== ownerHost)
|
|
865
|
-
throw new ConsentOwnerError(ownerHost);
|
|
866
|
-
const attestation = pairParamThreadAttestation(params);
|
|
867
|
-
if (attestation !== undefined)
|
|
868
|
-
await this.assertThread(attestation.alias, attestation.threadId);
|
|
869
|
-
this.assertWritable();
|
|
870
|
-
const existed = await this.store.hasConsentEdge(endpoints);
|
|
871
|
-
const result = await this.store.removeConsentEdge(endpoints);
|
|
872
|
-
await this.finishSettlements(result.settlements);
|
|
873
|
-
for (const alias of aliases)
|
|
874
|
-
this.settleWatchesForAlias(alias, "pair_removed", "operator");
|
|
875
|
-
if (existed)
|
|
876
|
-
await this.recordActivity("pairing", "routes_unpaired", [...aliases], true);
|
|
877
|
-
for (const alias of aliases)
|
|
878
|
-
this.kick(alias);
|
|
879
|
-
}
|
|
880
|
-
async resolvePairEndpoints(params, maySelect) {
|
|
881
|
-
const raw = pairParamAliases(params);
|
|
882
|
-
const aliases = [raw[0], raw[1]];
|
|
883
|
-
for (let index = 0; index < aliases.length; index += 1) {
|
|
884
|
-
if (PUBLIC_ALIAS.test(aliases[index]))
|
|
885
|
-
continue;
|
|
886
|
-
const selected = await this.resolveClaudeSelector(aliases[index], maySelect);
|
|
887
|
-
if (selected === undefined)
|
|
888
|
-
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "The Claude selector is unavailable.");
|
|
889
|
-
if (maySelect)
|
|
890
|
-
await this.selectClaude({ alias: aliases[index] });
|
|
891
|
-
aliases[index] = selected.alias;
|
|
892
|
-
}
|
|
893
|
-
const left = await this.store.inspectPrivateRoute(aliases[0]);
|
|
894
|
-
const right = await this.store.inspectPrivateRoute(aliases[1]);
|
|
895
|
-
if (left === undefined || right === undefined) {
|
|
896
|
-
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The pair endpoint is absent.");
|
|
922
|
+
if (result.installed)
|
|
923
|
+
this.revision += 1;
|
|
924
|
+
this.kick(alias);
|
|
925
|
+
return installed;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Two first sends to the same never-installed session race: both read no
|
|
929
|
+
* route and both mint a registration, and the loser's write is refused
|
|
930
|
+
* because the identity is now bound. That is a race, not a conflict — the
|
|
931
|
+
* session is exactly the one the loser resolved — so re-read once and adopt
|
|
932
|
+
* the registration that won instead of failing an honest send.
|
|
933
|
+
*/
|
|
934
|
+
async installWithConcurrentFirstSend(input, hostId, routeHandle) {
|
|
935
|
+
try {
|
|
936
|
+
return await this.store.installClaudeRoute(input);
|
|
937
|
+
}
|
|
938
|
+
catch (error) {
|
|
939
|
+
if (!(error instanceof BridgeError) || error.code !== "ROUTE_IDENTITY_ALREADY_REGISTERED")
|
|
940
|
+
throw error;
|
|
941
|
+
const bound = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
942
|
+
route.binding.hostId === hostId &&
|
|
943
|
+
route.binding.routeHandle === routeHandle);
|
|
944
|
+
if (bound === undefined)
|
|
945
|
+
throw error;
|
|
946
|
+
return await this.store.installClaudeRoute({
|
|
947
|
+
...input,
|
|
948
|
+
binding: { ...input.binding, registrationId: bound.binding.registrationId },
|
|
949
|
+
});
|
|
897
950
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Looks a Claude selector up without writing anything. Callers that may
|
|
954
|
+
* still refuse — a `--from` alias that must match the sending session, a
|
|
955
|
+
* native sender whose adapter-reported name must match discovery — resolve
|
|
956
|
+
* here first, refuse on the read, and only then materialize.
|
|
957
|
+
*
|
|
958
|
+
* THE ALIAS-COLLISION FENCE lives here (emb-94, moved into the send path by
|
|
959
|
+
* emb-104). Invariant: discovery is refreshed inside every send that
|
|
960
|
+
* addresses a Claude session, never only on the timer; a NAME currently
|
|
961
|
+
* shared by more than one live session is refused with a hard,
|
|
962
|
+
* non-retryable `PEER_ALIAS_COLLISION` naming that alias, so the broker
|
|
963
|
+
* never picks first and never installs a route under an ambiguous name;
|
|
964
|
+
* collisions stay sticky while a scan is incomplete
|
|
965
|
+
* (`collidingClaudeAliases` retention in refreshClaudeDiscovery); and
|
|
966
|
+
* `availablePeers` keeps filtering colliders so `status` never lists an
|
|
967
|
+
* unaddressable name.
|
|
968
|
+
*
|
|
969
|
+
* The fence is a fence on NAMES, not on sessions. A session UUID is
|
|
970
|
+
* unambiguous by construction, so a UUID selector — an explicit
|
|
971
|
+
* `send --to <uuid>`, an identity-pinned route addressed by its own handle,
|
|
972
|
+
* or a sender resolved from its inherited socket — passes the fence even
|
|
973
|
+
* while its display name collides. That is the emb-94 escape hatch: the
|
|
974
|
+
* operator can always reach an ambiguously named session, and a collision on
|
|
975
|
+
* a *sender's* name never silences that sender, whose identity was attested
|
|
976
|
+
* rather than typed. In-flight conversations are likewise unaffected:
|
|
977
|
+
* `reply` resolves by exact binding.
|
|
978
|
+
*/
|
|
979
|
+
async lookUpClaudeRoute(selector, hostId) {
|
|
980
|
+
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
981
|
+
if (PUBLIC_ALIAS.test(selector) && this.collidingClaudeAliases.has(selector)) {
|
|
982
|
+
throw new BridgeError("PEER_ALIAS_COLLISION", `The Claude alias ${selector} names more than one live session; rename one, or address the session by UUID.`);
|
|
983
|
+
}
|
|
984
|
+
const routed = (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
985
|
+
(hostId === undefined || route.binding.hostId === hostId) &&
|
|
986
|
+
(route.alias === selector || route.binding.routeHandle === selector));
|
|
987
|
+
const candidate = [...this.candidates.values()].find((row) => (hostId === undefined || row.adapter.identity.hostId === hostId) &&
|
|
988
|
+
(row.routeHandle === selector || row.alias === selector));
|
|
989
|
+
return { routed, candidate };
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* The name discovery currently shows for a looked-up session, which a caller
|
|
993
|
+
* compares against the alias it claimed before anything is written.
|
|
994
|
+
*/
|
|
995
|
+
claimedAlias(resolution) {
|
|
996
|
+
const alias = resolution.candidate?.alias ?? resolution.routed?.alias;
|
|
997
|
+
if (alias === undefined) {
|
|
998
|
+
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
|
|
999
|
+
}
|
|
1000
|
+
return alias;
|
|
1001
|
+
}
|
|
1002
|
+
/** Installs the looked-up session's route if it is not already current. */
|
|
1003
|
+
async materializeClaudeRoute(resolution) {
|
|
1004
|
+
const { routed, candidate } = resolution;
|
|
1005
|
+
if (candidate === undefined) {
|
|
1006
|
+
if (routed !== undefined)
|
|
1007
|
+
return routed;
|
|
1008
|
+
throw new BridgeError("CLAUDE_ROUTE_NOT_FOUND", "No live Claude session matches that name or UUID; run `embassy refresh` and check `embassy status`.");
|
|
1009
|
+
}
|
|
1010
|
+
if (routed !== undefined &&
|
|
1011
|
+
routed.binding.hostId === candidate.adapter.identity.hostId &&
|
|
1012
|
+
routed.binding.routeHandle === candidate.routeHandle &&
|
|
1013
|
+
routed.alias === this.claimableAlias(candidate, await this.store.listLogicalRoutes()))
|
|
1014
|
+
return routed;
|
|
1015
|
+
return await this.installClaudeRoute(candidate);
|
|
1016
|
+
}
|
|
1017
|
+
async resolveClaudeRoute(selector, hostId) {
|
|
1018
|
+
return await this.materializeClaudeRoute(await this.lookUpClaudeRoute(selector, hostId));
|
|
905
1019
|
}
|
|
906
1020
|
async assertThread(alias, threadId) {
|
|
907
1021
|
const route = await this.store.inspectPrivateRoute(alias);
|
|
908
|
-
|
|
1022
|
+
// No registration at all is its own condition — the state every Codex
|
|
1023
|
+
// task is in after a private state reset — and is never dressed up as a
|
|
1024
|
+
// thread mismatch, which would send the operator hunting a wrong id.
|
|
1025
|
+
if (route === undefined)
|
|
1026
|
+
throw new BridgeError("ROUTE_UNREGISTERED", "The route is not registered.");
|
|
1027
|
+
if (route.binding.provider !== "codex" || route.binding.routeHandle !== threadId) {
|
|
909
1028
|
throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the route.");
|
|
910
1029
|
}
|
|
911
1030
|
return route;
|
|
912
1031
|
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Direction follows the inherited principal, not the route table: a Claude
|
|
1034
|
+
* session or its target may have no route yet, because routes install on
|
|
1035
|
+
* first send. A Codex task sends to Claude sessions and shell peers; a
|
|
1036
|
+
* Claude session sends to Codex tasks and shell peers (Claude-to-Claude is
|
|
1037
|
+
* native); a shell peer sends to either.
|
|
1038
|
+
*/
|
|
1039
|
+
async send(params) {
|
|
1040
|
+
if (params.conversationId !== undefined)
|
|
1041
|
+
return await this.replyToConversation(params);
|
|
1042
|
+
const target = await this.store.inspectPrivateRoute(params.toAlias);
|
|
1043
|
+
if ("replyAddress" in params && params.replyAddress !== undefined) {
|
|
1044
|
+
// A UUID names a Claude session as surely as a name does: recognize it
|
|
1045
|
+
// here, or a Claude-to-Claude send addressed by UUID falls through to the
|
|
1046
|
+
// Codex path and is answered as an unknown target.
|
|
1047
|
+
const claudeTarget = target?.binding.provider === "claude" ||
|
|
1048
|
+
(target === undefined && [...this.candidates.values()].some((row) => row.alias === params.toAlias || row.routeHandle === params.toAlias));
|
|
1049
|
+
return claudeTarget ? { accepted: false, code: "route_mismatch" } : this.sendToCodex(params);
|
|
1050
|
+
}
|
|
1051
|
+
if ("threadId" in params && params.threadId !== undefined) {
|
|
1052
|
+
return target?.binding.provider === "codex" ? { accepted: false, code: "route_mismatch" } : this.sendToClaude(params);
|
|
1053
|
+
}
|
|
1054
|
+
return target === undefined || target.binding.provider === "claude" ? this.sendToClaude(params) : this.sendToCodex(params);
|
|
1055
|
+
}
|
|
913
1056
|
async sendToClaude(params) {
|
|
914
1057
|
try {
|
|
915
1058
|
this.assertWritable();
|
|
916
1059
|
const source = "peerToken" in params && params.peerToken !== undefined
|
|
917
1060
|
? await this.assertPeer({ alias: params.fromAlias, token: params.peerToken })
|
|
918
|
-
:
|
|
1061
|
+
: params.threadId === undefined
|
|
1062
|
+
? undefined
|
|
1063
|
+
: await this.assertThread(params.fromAlias, params.threadId);
|
|
1064
|
+
if (source === undefined)
|
|
1065
|
+
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The source route is absent.");
|
|
919
1066
|
const direct = await this.store.inspectPrivateRoute(params.toAlias);
|
|
920
|
-
|
|
921
|
-
if (
|
|
922
|
-
|
|
1067
|
+
let target;
|
|
1068
|
+
if (direct?.binding.provider === "peer") {
|
|
1069
|
+
target = direct;
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
const seen = await this.lookUpClaudeRoute(params.toAlias);
|
|
1073
|
+
// Admission before materialization: a send that is already going to be
|
|
1074
|
+
// refused for its own reasons — too large, outside the deadline
|
|
1075
|
+
// window, rate limited, or into a full queue — must not install a
|
|
1076
|
+
// route or displace another session on its way to that refusal.
|
|
1077
|
+
await this.store.assertEnqueueAdmissible({
|
|
1078
|
+
sourceAlias: params.fromAlias,
|
|
1079
|
+
targetAlias: this.claimedAlias(seen),
|
|
1080
|
+
direction: directionId(source.binding.provider, "claude"),
|
|
1081
|
+
sourceRegistrationId: source.binding.registrationId,
|
|
1082
|
+
body: params.text,
|
|
1083
|
+
});
|
|
1084
|
+
target = await this.materializeClaudeRoute(seen);
|
|
1085
|
+
}
|
|
923
1086
|
return await this.enqueueConversation({
|
|
924
1087
|
sourceAlias: params.fromAlias,
|
|
925
1088
|
targetAlias: target.alias,
|
|
@@ -927,13 +1090,10 @@ export class GatewayService {
|
|
|
927
1090
|
expectsReply: params.expectsReply,
|
|
928
1091
|
expectedSourceBinding: source.binding,
|
|
929
1092
|
expectedTargetBinding: target.binding,
|
|
930
|
-
...(params.trackIdleMinutes === undefined
|
|
931
|
-
? {}
|
|
932
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
933
1093
|
});
|
|
934
1094
|
}
|
|
935
1095
|
catch (error) {
|
|
936
|
-
return
|
|
1096
|
+
return sendRefusal(error, "peerToken" in params);
|
|
937
1097
|
}
|
|
938
1098
|
}
|
|
939
1099
|
async sendToCodex(params) {
|
|
@@ -947,13 +1107,18 @@ export class GatewayService {
|
|
|
947
1107
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
|
|
948
1108
|
}
|
|
949
1109
|
else {
|
|
1110
|
+
// The sending session's identity comes from its inherited socket, never
|
|
1111
|
+
// from --from. Look the session up read-only first: a send that is
|
|
1112
|
+
// about to be refused must not install, rename, or displace a route,
|
|
1113
|
+
// and must not settle another session's queued work.
|
|
950
1114
|
const resolved = await this.claudeAdapter(aliasHost(params.fromAlias))?.resolveReplyAddress?.(params.replyAddress);
|
|
951
1115
|
if (resolved === undefined)
|
|
952
1116
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The reply capability is stale.");
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1117
|
+
const seen = await this.lookUpClaudeRoute(resolved.routeHandle, aliasHost(params.fromAlias));
|
|
1118
|
+
if (this.claimedAlias(seen) !== params.fromAlias) {
|
|
1119
|
+
throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The --from alias does not name the sending Claude session.");
|
|
1120
|
+
}
|
|
1121
|
+
source = await this.materializeClaudeRoute(seen);
|
|
957
1122
|
}
|
|
958
1123
|
const target = await this.store.inspectPrivateRoute(params.toAlias);
|
|
959
1124
|
if (source === undefined || target === undefined) {
|
|
@@ -969,23 +1134,26 @@ export class GatewayService {
|
|
|
969
1134
|
expectsReply: params.expectsReply,
|
|
970
1135
|
expectedSourceBinding: source.binding,
|
|
971
1136
|
expectedTargetBinding: target.binding,
|
|
972
|
-
...(params.trackIdleMinutes === undefined
|
|
973
|
-
? {}
|
|
974
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
975
1137
|
});
|
|
976
1138
|
}
|
|
977
1139
|
catch (error) {
|
|
978
|
-
return
|
|
1140
|
+
return sendRefusal(error, "peerToken" in params);
|
|
979
1141
|
}
|
|
980
1142
|
}
|
|
981
|
-
|
|
1143
|
+
/**
|
|
1144
|
+
* A send addressed by conversation token: the caller must own one end, the
|
|
1145
|
+
* other end is the binding the conversation recorded, and the caller's
|
|
1146
|
+
* principal is the same attested one every send carries.
|
|
1147
|
+
*/
|
|
1148
|
+
async replyToConversation(params) {
|
|
982
1149
|
try {
|
|
983
1150
|
this.assertWritable();
|
|
984
1151
|
const conversation = this.conversations.get(params.conversationId);
|
|
985
1152
|
if (conversation === undefined)
|
|
986
1153
|
throw new BridgeError("CONVERSATION_NOT_FOUND", "The conversation is absent.");
|
|
987
|
-
const
|
|
988
|
-
const
|
|
1154
|
+
const caller = replyCaller(params);
|
|
1155
|
+
const callerBinding = await this.assertReplyCaller(conversation, caller);
|
|
1156
|
+
const callerAlias = caller.alias;
|
|
989
1157
|
const targetAlias = callerAlias === conversation.sourceAlias
|
|
990
1158
|
? conversation.targetAlias
|
|
991
1159
|
: conversation.sourceAlias;
|
|
@@ -995,27 +1163,9 @@ export class GatewayService {
|
|
|
995
1163
|
if (targetBinding === undefined) {
|
|
996
1164
|
throw new BridgeError("CONVERSATION_ROUTE_RETIRED", "The conversation endpoint is no longer available.");
|
|
997
1165
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
const enqueued = await this.enqueueNativeConversationReply({
|
|
1002
|
-
conversation,
|
|
1003
|
-
sourceAlias: callerAlias,
|
|
1004
|
-
sourceBinding: callerBinding,
|
|
1005
|
-
target: conversation.nativeTarget,
|
|
1006
|
-
text: params.text,
|
|
1007
|
-
exposeDeliveryToken: true,
|
|
1008
|
-
});
|
|
1009
|
-
if (enqueued.deliveryToken === undefined) {
|
|
1010
|
-
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1011
|
-
}
|
|
1012
|
-
return {
|
|
1013
|
-
accepted: true,
|
|
1014
|
-
code: "ok",
|
|
1015
|
-
conversationId: conversation.id,
|
|
1016
|
-
deliveryToken: enqueued.deliveryToken,
|
|
1017
|
-
};
|
|
1018
|
-
}
|
|
1166
|
+
// The target's route was installed when it first sent or was first
|
|
1167
|
+
// sent to; a reply never re-resolves it by alias. A retired binding is
|
|
1168
|
+
// a refusal at enqueue, never a silent retarget.
|
|
1019
1169
|
return await this.enqueueConversation({
|
|
1020
1170
|
sourceAlias: callerAlias,
|
|
1021
1171
|
targetAlias,
|
|
@@ -1023,14 +1173,11 @@ export class GatewayService {
|
|
|
1023
1173
|
expectsReply: true,
|
|
1024
1174
|
expectedSourceBinding: callerBinding,
|
|
1025
1175
|
expectedTargetBinding: targetBinding,
|
|
1026
|
-
...(params.trackIdleMinutes === undefined
|
|
1027
|
-
? {}
|
|
1028
|
-
: { trackIdleMinutes: params.trackIdleMinutes }),
|
|
1029
1176
|
existingConversation: conversation,
|
|
1030
1177
|
});
|
|
1031
1178
|
}
|
|
1032
1179
|
catch (error) {
|
|
1033
|
-
return
|
|
1180
|
+
return sendRefusal(error, params.peerToken !== undefined);
|
|
1034
1181
|
}
|
|
1035
1182
|
}
|
|
1036
1183
|
async assertReplyCaller(conversation, caller) {
|
|
@@ -1049,7 +1196,7 @@ export class GatewayService {
|
|
|
1049
1196
|
throw new BridgeError("CODEX_THREAD_MISMATCH", "The task attestation does not match the conversation.");
|
|
1050
1197
|
}
|
|
1051
1198
|
if (caller.kind === "claude") {
|
|
1052
|
-
if (
|
|
1199
|
+
if (route.binding.provider !== "claude") {
|
|
1053
1200
|
throw new BridgeError("CLAUDE_REPLY_ADDRESS_INVALID", "The inherited reply capability is required.");
|
|
1054
1201
|
}
|
|
1055
1202
|
const resolved = await this.claudeAdapter(route.binding.hostId)
|
|
@@ -1095,14 +1242,8 @@ export class GatewayService {
|
|
|
1095
1242
|
...(sourceBinding === undefined ? {} : { sourceBinding: { ...sourceBinding } }),
|
|
1096
1243
|
...(targetBinding === undefined ? {} : { targetBinding: { ...targetBinding } }),
|
|
1097
1244
|
expectsReply: input.expectsReply,
|
|
1098
|
-
pair: true,
|
|
1099
1245
|
nextSequence: 0,
|
|
1100
1246
|
};
|
|
1101
|
-
const previousWatch = this.progressWatches.get(conversation.id);
|
|
1102
|
-
const previousWatchEvents = this.progressWatchEvents.length;
|
|
1103
|
-
if (input.skipWatch !== true) {
|
|
1104
|
-
await this.updateProgressWatch(conversation, input.sourceAlias, input.text, input.trackIdleMinutes);
|
|
1105
|
-
}
|
|
1106
1247
|
const sequence = conversation.nextSequence++;
|
|
1107
1248
|
let enqueued;
|
|
1108
1249
|
try {
|
|
@@ -1123,12 +1264,10 @@ export class GatewayService {
|
|
|
1123
1264
|
});
|
|
1124
1265
|
}
|
|
1125
1266
|
catch (error) {
|
|
1126
|
-
this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
|
|
1127
1267
|
conversation.nextSequence -= 1;
|
|
1128
1268
|
throw error;
|
|
1129
1269
|
}
|
|
1130
1270
|
if (!enqueued.accepted || enqueued.messageId === undefined || enqueued.deliveryToken === undefined) {
|
|
1131
|
-
this.restoreProgressWatch(conversation.id, previousWatch, previousWatchEvents);
|
|
1132
1271
|
conversation.nextSequence -= 1;
|
|
1133
1272
|
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1134
1273
|
}
|
|
@@ -1151,6 +1290,23 @@ export class GatewayService {
|
|
|
1151
1290
|
deliveryToken: enqueued.deliveryToken,
|
|
1152
1291
|
};
|
|
1153
1292
|
}
|
|
1293
|
+
/**
|
|
1294
|
+
* A renamed Claude session is the same principal under a new display name,
|
|
1295
|
+
* so its open conversations follow it: the registration, not the alias, is
|
|
1296
|
+
* the identity, and a reply under the current name must still resolve.
|
|
1297
|
+
*/
|
|
1298
|
+
renameConversationCoordinates(oldAlias, newAlias, registrationId) {
|
|
1299
|
+
for (const conversation of this.conversations.values()) {
|
|
1300
|
+
if (conversation.sourceAlias === oldAlias &&
|
|
1301
|
+
conversation.sourceBinding?.registrationId === registrationId) {
|
|
1302
|
+
conversation.sourceAlias = newAlias;
|
|
1303
|
+
}
|
|
1304
|
+
if (conversation.targetAlias === oldAlias &&
|
|
1305
|
+
conversation.targetBinding?.registrationId === registrationId) {
|
|
1306
|
+
conversation.targetAlias = newAlias;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1154
1310
|
rememberConversation(conversation) {
|
|
1155
1311
|
this.conversations.delete(conversation.id);
|
|
1156
1312
|
this.conversations.set(conversation.id, conversation);
|
|
@@ -1213,12 +1369,7 @@ export class GatewayService {
|
|
|
1213
1369
|
if (!this.running || this.closing)
|
|
1214
1370
|
return;
|
|
1215
1371
|
const route = await this.store.inspectPrivateRoute(targetAlias);
|
|
1216
|
-
const
|
|
1217
|
-
const transientTarget = transient === undefined
|
|
1218
|
-
? undefined
|
|
1219
|
-
: this.conversations.get(transient.conversationId)?.nativeTarget;
|
|
1220
|
-
const registration = preferredRegistrationId ?? route?.binding.registrationId ??
|
|
1221
|
-
transientTarget?.binding.registrationId;
|
|
1372
|
+
const registration = preferredRegistrationId ?? route?.binding.registrationId;
|
|
1222
1373
|
if (registration === undefined)
|
|
1223
1374
|
return;
|
|
1224
1375
|
this.startRunner(targetAlias, registration, "any");
|
|
@@ -1299,34 +1450,27 @@ export class GatewayService {
|
|
|
1299
1450
|
const conversation = messageContext === undefined
|
|
1300
1451
|
? undefined
|
|
1301
1452
|
: this.conversations.get(messageContext.conversationId);
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
const
|
|
1306
|
-
const
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
registrationMode: "selected_live_peer",
|
|
1311
|
-
enabled: true,
|
|
1312
|
-
}
|
|
1313
|
-
: (persistedTarget === undefined
|
|
1314
|
-
? undefined
|
|
1315
|
-
: persistedTarget);
|
|
1316
|
-
const source = await this.store.inspectPrivateRoute(attempt.sourceAlias);
|
|
1453
|
+
// Resolve by registration, not by the alias captured at reserve: a
|
|
1454
|
+
// session renamed between reserve and dispatch is the same route and
|
|
1455
|
+
// must still be delivered to, under whatever name it carries now.
|
|
1456
|
+
const installed = await this.store.listLogicalRoutes();
|
|
1457
|
+
const byRegistration = (registration, alias) => installed.find((route) => route.binding.registrationId === registration) ??
|
|
1458
|
+
installed.find((route) => route.alias === alias);
|
|
1459
|
+
const target = byRegistration(attempt.targetRegistrationId, attempt.targetAlias);
|
|
1460
|
+
const source = byRegistration(attempt.sourceRegistrationId, attempt.sourceAlias);
|
|
1317
1461
|
if (this.closing || !this.running) {
|
|
1318
1462
|
await this.settleAttemptForShutdown(attempt.messageId, attempt.attemptId);
|
|
1319
1463
|
return false;
|
|
1320
1464
|
}
|
|
1321
1465
|
if (target === undefined ||
|
|
1322
1466
|
target.binding.registrationId !== attempt.targetRegistrationId ||
|
|
1323
|
-
|
|
1467
|
+
source?.binding.registrationId !== attempt.sourceRegistrationId) {
|
|
1324
1468
|
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "failed", "ROUTE_UNREGISTERED");
|
|
1325
1469
|
return false;
|
|
1326
1470
|
}
|
|
1327
1471
|
if (target.registrationMode === "federated_peer") {
|
|
1328
1472
|
const peer = this.peerClients.get(target.binding.hostId);
|
|
1329
|
-
if (peer === undefined || source
|
|
1473
|
+
if (peer === undefined || source.registrationMode === "federated_peer") {
|
|
1330
1474
|
await this.resolvePrewrite(attempt.messageId, attempt.attemptId, "requeue", "PEER_TUNNEL_UNAVAILABLE");
|
|
1331
1475
|
return false;
|
|
1332
1476
|
}
|
|
@@ -1337,8 +1481,6 @@ export class GatewayService {
|
|
|
1337
1481
|
const params = {
|
|
1338
1482
|
originAttemptId: attempt.attemptId, originMessageId: attempt.messageId,
|
|
1339
1483
|
source: sourceEndpoint, target: targetEndpoint,
|
|
1340
|
-
edgeRef: peerEdgeRef([sourceEndpoint, targetEndpoint]),
|
|
1341
|
-
edgeOwnerHost: [sourceEndpoint.host, targetEndpoint.host].sort()[0],
|
|
1342
1484
|
deadlineAt: attempt.deadlineAt, expectsReply: conversation?.expectsReply ?? true,
|
|
1343
1485
|
body: attempt.body,
|
|
1344
1486
|
...(attempt.steer === true ? { steer: true } : {}),
|
|
@@ -1415,19 +1557,18 @@ export class GatewayService {
|
|
|
1415
1557
|
attemptId: attempt.attemptId,
|
|
1416
1558
|
sourceAlias: attempt.sourceAlias,
|
|
1417
1559
|
sourceProvider: parsed.sourceProvider,
|
|
1418
|
-
|
|
1560
|
+
// The route's CURRENT alias, not the one captured at reserve. The
|
|
1561
|
+
// provider adapters compare this against the alias they hold for the
|
|
1562
|
+
// binding, so a session renamed between reserve and dispatch would
|
|
1563
|
+
// otherwise fail its own delivery over a stale display name.
|
|
1564
|
+
targetAlias: target.alias,
|
|
1419
1565
|
conversationId,
|
|
1420
1566
|
binding: target.binding,
|
|
1421
|
-
authorization:
|
|
1422
|
-
? "selected_route"
|
|
1423
|
-
: "native_reply",
|
|
1567
|
+
authorization: "selected_route",
|
|
1424
1568
|
messageId: attempt.messageId,
|
|
1425
1569
|
text: attempt.body,
|
|
1426
1570
|
expectsReply: conversation?.expectsReply ?? true,
|
|
1427
1571
|
deadlineAt: attempt.deadlineAt,
|
|
1428
|
-
...(this.progressWatches.has(conversationId)
|
|
1429
|
-
? { progressWatchActive: true }
|
|
1430
|
-
: {}),
|
|
1431
1572
|
...(attempt.steer === true ? { steer: true, queuedAhead: 0 } : {}),
|
|
1432
1573
|
authorizeWrite: async (evidence) => {
|
|
1433
1574
|
if (evidence.attemptId !== attempt.attemptId)
|
|
@@ -1452,8 +1593,7 @@ export class GatewayService {
|
|
|
1452
1593
|
if (authorized.status === "authorized") {
|
|
1453
1594
|
armed = true;
|
|
1454
1595
|
if (target.binding.provider === "claude" &&
|
|
1455
|
-
messageContext?.expectsReply === true
|
|
1456
|
-
source !== undefined) {
|
|
1596
|
+
messageContext?.expectsReply === true) {
|
|
1457
1597
|
this.installPendingClaudeReply({
|
|
1458
1598
|
messageId: attempt.messageId,
|
|
1459
1599
|
conversationId,
|
|
@@ -1637,30 +1777,31 @@ export class GatewayService {
|
|
|
1637
1777
|
}
|
|
1638
1778
|
}
|
|
1639
1779
|
this.revision += 1;
|
|
1640
|
-
void this.publish().catch(() => undefined);
|
|
1641
1780
|
}
|
|
1642
1781
|
async onClaudeMessage(event) {
|
|
1643
1782
|
const target = await this.store.inspectPrivateRoute(event.targetAlias);
|
|
1644
|
-
const localHost = this.config.hostId
|
|
1783
|
+
const localHost = this.config.hostId;
|
|
1645
1784
|
if (target === undefined || (target.binding.hostId !== event.endpoint.hostId &&
|
|
1646
1785
|
!(target.registrationMode === "federated_peer" && event.endpoint.hostId === localHost))) {
|
|
1647
1786
|
throw new BridgeError("ROUTE_NOT_AVAILABLE", "The native target is absent.");
|
|
1648
1787
|
}
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
}
|
|
1788
|
+
// A native SendMessage is this session's send: its route installs here,
|
|
1789
|
+
// from the exact identity the adapter attested, so the Codex task can
|
|
1790
|
+
// reply through the ordinary path. The adapter's name for the sender must
|
|
1791
|
+
// be the name discovery shows for that UUID; the check runs on a read-only
|
|
1792
|
+
// lookup so a refusal writes nothing.
|
|
1793
|
+
this.assertWritable();
|
|
1794
|
+
const seen = await this.lookUpClaudeRoute(event.endpoint.routeHandle, event.endpoint.hostId);
|
|
1795
|
+
if (this.claimedAlias(seen) !== event.sourceAlias) {
|
|
1796
|
+
throw new BridgeError("CLAUDE_ROUTE_MISMATCH", "The native sender's name does not match its discovered session.");
|
|
1797
|
+
}
|
|
1798
|
+
const source = await this.materializeClaudeRoute(seen);
|
|
1799
|
+
const sourceBinding = source.binding;
|
|
1659
1800
|
const conversationId = createGatewayConversationId();
|
|
1660
1801
|
const steer = target.binding.provider === "codex" && event.text.startsWith("STEER:") && this.config.steeringEnabled;
|
|
1661
1802
|
this.assertWritable();
|
|
1662
1803
|
const enqueued = await this.store.enqueueNativeIngress({
|
|
1663
|
-
source: { alias:
|
|
1804
|
+
source: { alias: source.alias, binding: sourceBinding },
|
|
1664
1805
|
targetAlias: event.targetAlias,
|
|
1665
1806
|
expectedTargetRegistrationId: target.binding.registrationId,
|
|
1666
1807
|
body: event.text,
|
|
@@ -1673,13 +1814,11 @@ export class GatewayService {
|
|
|
1673
1814
|
}
|
|
1674
1815
|
const conversation = {
|
|
1675
1816
|
id: conversationId,
|
|
1676
|
-
sourceAlias:
|
|
1817
|
+
sourceAlias: source.alias,
|
|
1677
1818
|
targetAlias: event.targetAlias,
|
|
1678
1819
|
sourceBinding,
|
|
1679
1820
|
targetBinding: target.binding,
|
|
1680
1821
|
expectsReply: true,
|
|
1681
|
-
...(enqueued.pair === true ? { pair: true } : {}),
|
|
1682
|
-
nativeTarget: { alias: event.sourceAlias, binding: sourceBinding },
|
|
1683
1822
|
nextSequence: 1,
|
|
1684
1823
|
};
|
|
1685
1824
|
this.rememberConversation(conversation);
|
|
@@ -1784,65 +1923,21 @@ export class GatewayService {
|
|
|
1784
1923
|
const targetBinding = targetAlias === conversation.targetAlias
|
|
1785
1924
|
? conversation.targetBinding
|
|
1786
1925
|
: conversation.sourceBinding;
|
|
1787
|
-
if (
|
|
1788
|
-
sourceAlias !== conversation.nativeTarget.alias) {
|
|
1789
|
-
if (sourceBinding === undefined || targetBinding === undefined)
|
|
1790
|
-
return;
|
|
1791
|
-
const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
|
|
1792
|
-
if (currentSource === undefined ||
|
|
1793
|
-
!sameBinding(currentSource.binding, sourceBinding))
|
|
1794
|
-
return;
|
|
1795
|
-
await this.enqueueConversation({
|
|
1796
|
-
sourceAlias: targetAlias,
|
|
1797
|
-
targetAlias: sourceAlias,
|
|
1798
|
-
text,
|
|
1799
|
-
expectsReply: true,
|
|
1800
|
-
expectedSourceBinding: targetBinding,
|
|
1801
|
-
expectedTargetBinding: sourceBinding,
|
|
1802
|
-
existingConversation: conversation,
|
|
1803
|
-
});
|
|
1926
|
+
if (sourceBinding === undefined || targetBinding === undefined)
|
|
1804
1927
|
return;
|
|
1805
|
-
|
|
1806
|
-
if (
|
|
1928
|
+
const currentSource = await this.store.inspectPrivateRoute(sourceAlias);
|
|
1929
|
+
if (currentSource === undefined ||
|
|
1930
|
+
!sameBinding(currentSource.binding, sourceBinding))
|
|
1807
1931
|
return;
|
|
1808
|
-
await this.
|
|
1809
|
-
conversation,
|
|
1932
|
+
await this.enqueueConversation({
|
|
1810
1933
|
sourceAlias: targetAlias,
|
|
1811
|
-
|
|
1812
|
-
target: conversation.nativeTarget,
|
|
1934
|
+
targetAlias: sourceAlias,
|
|
1813
1935
|
text,
|
|
1814
|
-
});
|
|
1815
|
-
}
|
|
1816
|
-
async enqueueNativeConversationReply(input) {
|
|
1817
|
-
const sequence = input.conversation.nextSequence++;
|
|
1818
|
-
let enqueued;
|
|
1819
|
-
try {
|
|
1820
|
-
enqueued = await this.store.enqueueNativeReply({
|
|
1821
|
-
sourceAlias: input.sourceAlias,
|
|
1822
|
-
expectedSourceRegistrationId: input.sourceBinding.registrationId,
|
|
1823
|
-
target: input.target,
|
|
1824
|
-
body: input.text,
|
|
1825
|
-
dedupeKey: `${input.conversation.id}:${sequence}`,
|
|
1826
|
-
conversationIdSuffix: input.conversation.id.slice(-8),
|
|
1827
|
-
...(input.conversation.pair === true ? { pair: true } : {}),
|
|
1828
|
-
...(input.exposeDeliveryToken === true ? { exposeDeliveryToken: true } : {}),
|
|
1829
|
-
});
|
|
1830
|
-
}
|
|
1831
|
-
catch (error) {
|
|
1832
|
-
input.conversation.nextSequence -= 1;
|
|
1833
|
-
throw error;
|
|
1834
|
-
}
|
|
1835
|
-
if (!enqueued.accepted || enqueued.messageId === undefined) {
|
|
1836
|
-
input.conversation.nextSequence -= 1;
|
|
1837
|
-
throw new BridgeError("MESSAGE_NOT_ACCEPTED", "The message was not accepted.");
|
|
1838
|
-
}
|
|
1839
|
-
this.messageContexts.set(enqueued.messageId, {
|
|
1840
|
-
conversationId: input.conversation.id,
|
|
1841
1936
|
expectsReply: true,
|
|
1937
|
+
expectedSourceBinding: targetBinding,
|
|
1938
|
+
expectedTargetBinding: sourceBinding,
|
|
1939
|
+
existingConversation: conversation,
|
|
1842
1940
|
});
|
|
1843
|
-
this.kick(input.target.alias, input.target.binding.registrationId);
|
|
1844
|
-
this.scheduleWake();
|
|
1845
|
-
return enqueued;
|
|
1846
1941
|
}
|
|
1847
1942
|
installPendingClaudeReply(row) {
|
|
1848
1943
|
const key = `${row.targetBinding.hostId}\0${row.targetBinding.routeHandle}`;
|
|
@@ -1909,173 +2004,6 @@ export class GatewayService {
|
|
|
1909
2004
|
existingConversation: conversation,
|
|
1910
2005
|
});
|
|
1911
2006
|
}
|
|
1912
|
-
async updateProgressWatch(conversation, actorAlias, text, trackIdleMinutes) {
|
|
1913
|
-
const existing = this.progressWatches.get(conversation.id);
|
|
1914
|
-
if (this.config.trackingEnabled === false) {
|
|
1915
|
-
if (existing !== undefined) {
|
|
1916
|
-
this.settleProgressWatch(existing, "gateway", "tracking_disabled");
|
|
1917
|
-
}
|
|
1918
|
-
return;
|
|
1919
|
-
}
|
|
1920
|
-
if (text.startsWith("DONE:")) {
|
|
1921
|
-
if (existing !== undefined) {
|
|
1922
|
-
const actor = actorAlias === existing.ownerAlias
|
|
1923
|
-
? "owner"
|
|
1924
|
-
: actorAlias === existing.workerAlias
|
|
1925
|
-
? "worker"
|
|
1926
|
-
: undefined;
|
|
1927
|
-
if (actor !== undefined)
|
|
1928
|
-
this.settleProgressWatch(existing, actor, "done");
|
|
1929
|
-
}
|
|
1930
|
-
return;
|
|
1931
|
-
}
|
|
1932
|
-
const explicit = text.startsWith("TRACK:") || trackIdleMinutes !== undefined;
|
|
1933
|
-
if (existing !== undefined && !explicit) {
|
|
1934
|
-
this.progressWatches.set(conversation.id, {
|
|
1935
|
-
...existing,
|
|
1936
|
-
...recordProgressWatchActivity(existing, this.now().getTime()),
|
|
1937
|
-
});
|
|
1938
|
-
return;
|
|
1939
|
-
}
|
|
1940
|
-
if (!explicit)
|
|
1941
|
-
return;
|
|
1942
|
-
if (existing !== undefined && actorAlias !== existing.ownerAlias) {
|
|
1943
|
-
throw new BridgeError("PROGRESS_WATCH_REPLACEMENT_OWNER_REQUIRED", "Only the progress-watch owner may replace its watch.");
|
|
1944
|
-
}
|
|
1945
|
-
const capacity = Math.min(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_HARD_CAPACITY);
|
|
1946
|
-
if (existing === undefined && this.progressWatches.size >= capacity) {
|
|
1947
|
-
throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "Progress-watch capacity is full.");
|
|
1948
|
-
}
|
|
1949
|
-
const owner = await this.store.inspectPrivateRoute(actorAlias);
|
|
1950
|
-
const workerAlias = actorAlias === conversation.sourceAlias
|
|
1951
|
-
? conversation.targetAlias
|
|
1952
|
-
: conversation.sourceAlias;
|
|
1953
|
-
const worker = await this.store.inspectPrivateRoute(workerAlias);
|
|
1954
|
-
if (owner === undefined || worker === undefined) {
|
|
1955
|
-
throw new BridgeError("PROGRESS_WATCH_ENDPOINT_NOT_FOUND", "A progress-watch endpoint is absent.");
|
|
1956
|
-
}
|
|
1957
|
-
const idleMs = trackIdleMinutes === undefined
|
|
1958
|
-
? PROGRESS_WATCH_DEFAULT_IDLE_MS
|
|
1959
|
-
: trackIdleMinutes * 60_000;
|
|
1960
|
-
const watch = createProgressWatch({
|
|
1961
|
-
conversationId: conversation.id,
|
|
1962
|
-
ownerAlias: actorAlias,
|
|
1963
|
-
workerAlias,
|
|
1964
|
-
idleMs,
|
|
1965
|
-
at: this.now().getTime(),
|
|
1966
|
-
});
|
|
1967
|
-
this.progressWatches.set(conversation.id, {
|
|
1968
|
-
...watch,
|
|
1969
|
-
ownerRegistrationId: owner.binding.registrationId,
|
|
1970
|
-
workerRegistrationId: worker.binding.registrationId,
|
|
1971
|
-
});
|
|
1972
|
-
this.recordProgressWatchEvent({
|
|
1973
|
-
watch,
|
|
1974
|
-
kind: existing === undefined ? "opened" : "replaced",
|
|
1975
|
-
actor: "owner",
|
|
1976
|
-
});
|
|
1977
|
-
}
|
|
1978
|
-
restoreProgressWatch(conversationId, previous, eventLength) {
|
|
1979
|
-
if (previous === undefined)
|
|
1980
|
-
this.progressWatches.delete(conversationId);
|
|
1981
|
-
else
|
|
1982
|
-
this.progressWatches.set(conversationId, previous);
|
|
1983
|
-
this.progressWatchEvents.length = eventLength;
|
|
1984
|
-
}
|
|
1985
|
-
untrack(conversationId) {
|
|
1986
|
-
const watch = this.progressWatches.get(conversationId);
|
|
1987
|
-
if (watch === undefined) {
|
|
1988
|
-
throw new BridgeError("PROGRESS_WATCH_NOT_FOUND", "The progress watch is absent.");
|
|
1989
|
-
}
|
|
1990
|
-
this.settleProgressWatch(watch, "operator", "untracked");
|
|
1991
|
-
}
|
|
1992
|
-
settleWatchesForAlias(alias, reason, actor) {
|
|
1993
|
-
for (const watch of [...this.progressWatches.values()]) {
|
|
1994
|
-
if (watch.ownerAlias === alias || watch.workerAlias === alias) {
|
|
1995
|
-
this.settleProgressWatch(watch, actor, reason);
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
settleProgressWatch(watch, actor, reason) {
|
|
2000
|
-
if (!this.progressWatches.delete(watch.conversationId))
|
|
2001
|
-
return;
|
|
2002
|
-
this.recordProgressWatchEvent({ watch, kind: "settled", actor, reason });
|
|
2003
|
-
}
|
|
2004
|
-
recordProgressWatchEvent(input) {
|
|
2005
|
-
this.progressWatchEvents.push({
|
|
2006
|
-
sequence: (this.progressWatchEvents.at(-1)?.sequence ?? 0) + 1,
|
|
2007
|
-
timestamp: this.now().toISOString(),
|
|
2008
|
-
conversationIdSuffix: input.watch.conversationId.slice(-8),
|
|
2009
|
-
ownerAlias: input.watch.ownerAlias,
|
|
2010
|
-
workerAlias: input.watch.workerAlias,
|
|
2011
|
-
kind: input.kind,
|
|
2012
|
-
actor: input.actor,
|
|
2013
|
-
...(input.reason === undefined ? {} : { reason: input.reason }),
|
|
2014
|
-
});
|
|
2015
|
-
while (this.progressWatchEvents.length > gatewayPublicSnapshotLimits.progressWatchEvents) {
|
|
2016
|
-
this.progressWatchEvents.shift();
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
async processProgressWatches() {
|
|
2020
|
-
const at = this.now().getTime();
|
|
2021
|
-
for (const watch of [...this.progressWatches.values()]) {
|
|
2022
|
-
const owner = await this.store.inspectPrivateRoute(watch.ownerAlias);
|
|
2023
|
-
const worker = await this.store.inspectPrivateRoute(watch.workerAlias);
|
|
2024
|
-
if (owner?.binding.registrationId !== watch.ownerRegistrationId ||
|
|
2025
|
-
worker?.binding.registrationId !== watch.workerRegistrationId) {
|
|
2026
|
-
this.settleProgressWatch(watch, "gateway", "endpoint_retired");
|
|
2027
|
-
continue;
|
|
2028
|
-
}
|
|
2029
|
-
const ownerObservation = this.routeObservations.get(watch.ownerAlias);
|
|
2030
|
-
const workerObservation = this.routeObservations.get(watch.workerAlias);
|
|
2031
|
-
const due = inspectProgressWatchDue(watch, {
|
|
2032
|
-
at,
|
|
2033
|
-
bothIdle: ownerObservation?.state === "idle" &&
|
|
2034
|
-
workerObservation?.state === "idle" &&
|
|
2035
|
-
this.routeObservationStillCurrent(ownerObservation) &&
|
|
2036
|
-
this.routeObservationStillCurrent(workerObservation),
|
|
2037
|
-
});
|
|
2038
|
-
if (due.kind === "not_due")
|
|
2039
|
-
continue;
|
|
2040
|
-
if (due.kind === "rescheduled") {
|
|
2041
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2042
|
-
...watch,
|
|
2043
|
-
...due.watch,
|
|
2044
|
-
});
|
|
2045
|
-
continue;
|
|
2046
|
-
}
|
|
2047
|
-
if (due.kind === "settled") {
|
|
2048
|
-
this.settleProgressWatch(watch, "gateway", "idle_timeout");
|
|
2049
|
-
continue;
|
|
2050
|
-
}
|
|
2051
|
-
const conversation = this.conversations.get(watch.conversationId);
|
|
2052
|
-
if (conversation === undefined) {
|
|
2053
|
-
this.settleProgressWatch(watch, "gateway", "endpoint_retired");
|
|
2054
|
-
continue;
|
|
2055
|
-
}
|
|
2056
|
-
const text = `[Embassy automated liveness check — ${due.nudgeNumber} / Embassy 自动活跃检查 — ${due.nudgeNumber}]\nReply with the result or a status. / 请回复结果或状态。`;
|
|
2057
|
-
try {
|
|
2058
|
-
await this.enqueueConversation({
|
|
2059
|
-
sourceAlias: watch.ownerAlias,
|
|
2060
|
-
targetAlias: watch.workerAlias,
|
|
2061
|
-
text,
|
|
2062
|
-
expectsReply: false,
|
|
2063
|
-
existingConversation: conversation,
|
|
2064
|
-
skipWatch: true,
|
|
2065
|
-
});
|
|
2066
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2067
|
-
...watch,
|
|
2068
|
-
...commitProgressWatchNudge(watch, { at, nudgeNumber: due.nudgeNumber }),
|
|
2069
|
-
});
|
|
2070
|
-
}
|
|
2071
|
-
catch {
|
|
2072
|
-
this.progressWatches.set(watch.conversationId, {
|
|
2073
|
-
...watch,
|
|
2074
|
-
...deferProgressWatchNudge(watch, at),
|
|
2075
|
-
});
|
|
2076
|
-
}
|
|
2077
|
-
}
|
|
2078
|
-
}
|
|
2079
2007
|
async finishSettlements(settlements) {
|
|
2080
2008
|
await Promise.all(settlements.map(async (settlement) => this.finishSettlement(settlement)));
|
|
2081
2009
|
}
|
|
@@ -2118,13 +2046,13 @@ export class GatewayService {
|
|
|
2118
2046
|
const now = this.now().getTime();
|
|
2119
2047
|
const due = [
|
|
2120
2048
|
this.nextDiscoveryAt || now + DISCOVERY_INTERVAL_MS,
|
|
2121
|
-
this.
|
|
2122
|
-
...(
|
|
2049
|
+
this.nextManagedCodexCheckAt || now + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS,
|
|
2050
|
+
...(this.config.peerNodes.length > 0
|
|
2123
2051
|
? [this.nextPeerRefreshAt || now + PEER_REFRESH_INTERVAL_MS]
|
|
2124
2052
|
: []),
|
|
2053
|
+
...this.ephemeralPeerExpiries.values(),
|
|
2125
2054
|
...(deadlineAt === undefined ? [] : [Date.parse(deadlineAt)]),
|
|
2126
2055
|
...[...this.pendingClaudeReplies.values()].flatMap((rows) => rows.map((row) => Date.parse(row.deadlineAt))),
|
|
2127
|
-
...[...this.progressWatches.values()].map((watch) => Date.parse(watch.nextActionAt)),
|
|
2128
2056
|
].filter(Number.isFinite);
|
|
2129
2057
|
const delay = Math.max(0, Math.min(...due) - now);
|
|
2130
2058
|
this.wakeTimer = this.timers.setTimeout(() => {
|
|
@@ -2139,14 +2067,14 @@ export class GatewayService {
|
|
|
2139
2067
|
const now = this.now();
|
|
2140
2068
|
await this.finishSettlements(await this.store.expireDueMessages(now));
|
|
2141
2069
|
this.pruneExpiredPendingClaudeReplies(now.getTime());
|
|
2142
|
-
await this.
|
|
2070
|
+
await this.expireEphemeralPeers(now.getTime());
|
|
2143
2071
|
if (now.getTime() >= this.nextDiscoveryAt) {
|
|
2144
2072
|
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
2145
2073
|
this.nextDiscoveryAt = now.getTime() + DISCOVERY_INTERVAL_MS;
|
|
2146
2074
|
}
|
|
2147
|
-
if (now.getTime() >= this.
|
|
2148
|
-
await this.
|
|
2149
|
-
this.
|
|
2075
|
+
if (now.getTime() >= this.nextManagedCodexCheckAt) {
|
|
2076
|
+
await this.refreshManagedCodexSocket().catch(() => undefined);
|
|
2077
|
+
this.nextManagedCodexCheckAt = now.getTime() + MANAGED_CODEX_SOCKET_CHECK_INTERVAL_MS;
|
|
2150
2078
|
}
|
|
2151
2079
|
if (this.nextPeerRefreshAt > 0 && now.getTime() >= this.nextPeerRefreshAt) {
|
|
2152
2080
|
const operation = this.refreshPeers();
|
|
@@ -2163,12 +2091,11 @@ export class GatewayService {
|
|
|
2163
2091
|
return;
|
|
2164
2092
|
for (const target of await this.store.inspectDispatchableTargets())
|
|
2165
2093
|
this.kick(target);
|
|
2166
|
-
await this.publish();
|
|
2167
2094
|
this.scheduleWake();
|
|
2168
2095
|
}
|
|
2169
2096
|
async refreshPeers() {
|
|
2170
|
-
const localHost = this.config.hostId
|
|
2171
|
-
await Promise.all(
|
|
2097
|
+
const localHost = this.config.hostId;
|
|
2098
|
+
await Promise.all(this.config.peerNodes.map(async (peerHost) => {
|
|
2172
2099
|
let failureCode = "PEER_TUNNEL_UNAVAILABLE";
|
|
2173
2100
|
try {
|
|
2174
2101
|
let client = this.peerClients.get(peerHost);
|
|
@@ -2227,33 +2154,52 @@ export class GatewayService {
|
|
|
2227
2154
|
if (viewChanged)
|
|
2228
2155
|
this.revision += 1;
|
|
2229
2156
|
}
|
|
2230
|
-
catch {
|
|
2157
|
+
catch (error) {
|
|
2231
2158
|
this.peerClients.get(peerHost)?.close();
|
|
2232
2159
|
this.peerClients.delete(peerHost);
|
|
2233
2160
|
if (!this.running || this.closing)
|
|
2234
2161
|
return;
|
|
2162
|
+
// A wire-version disagreement is not a tunnel fault: name it so an operator
|
|
2163
|
+
// upgrades the lagging node instead of debugging SSH.
|
|
2164
|
+
if (error instanceof PeerConnectionLostError && error.failureClass === "protocol")
|
|
2165
|
+
failureCode = "PEER_PROTOCOL_MISMATCH";
|
|
2235
2166
|
this.recordPeerFailure(peerHost, failureCode);
|
|
2236
2167
|
const observedAt = this.now().toISOString();
|
|
2168
|
+
const mirrorCode = failureCode === "PEER_PROTOCOL_MISMATCH" ? failureCode : "PEER_TUNNEL_UNAVAILABLE";
|
|
2237
2169
|
for (const route of await this.store.inspectPrivateRoutes())
|
|
2238
2170
|
if (route.registrationMode === "federated_peer" &&
|
|
2239
2171
|
route.binding.hostId === peerHost)
|
|
2240
2172
|
this.peerRouteViews.set(route.alias, { route: route.binding,
|
|
2241
|
-
state: "unobserved", observedAt, safeErrorCode:
|
|
2173
|
+
state: "unobserved", observedAt, safeErrorCode: mirrorCode });
|
|
2242
2174
|
}
|
|
2243
2175
|
}));
|
|
2244
2176
|
}
|
|
2245
2177
|
async refreshClaudeDiscovery() {
|
|
2246
2178
|
const seen = new Set();
|
|
2179
|
+
const nextCollisions = new Set();
|
|
2180
|
+
const refreshedHosts = new Set();
|
|
2247
2181
|
for (const adapter of this.adapters) {
|
|
2248
2182
|
if (adapter.identity.provider !== "claude" || adapter.discoverClaudePeers === undefined)
|
|
2249
2183
|
continue;
|
|
2250
2184
|
const snapshot = await adapter.discoverClaudePeers();
|
|
2251
2185
|
const observedAt = this.now().toISOString();
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2186
|
+
const peers = snapshot.peers.filter((peer) => PUBLIC_ALIAS.test(peer.alias) &&
|
|
2187
|
+
peer.alias.endsWith(`@${adapter.identity.hostId}`) && PRIVATE_HANDLE.test(peer.routeHandle));
|
|
2188
|
+
const aliasHandles = new Map();
|
|
2189
|
+
for (const peer of peers) {
|
|
2190
|
+
const handles = aliasHandles.get(peer.alias) ?? new Set();
|
|
2191
|
+
handles.add(peer.routeHandle);
|
|
2192
|
+
aliasHandles.set(peer.alias, handles);
|
|
2193
|
+
}
|
|
2194
|
+
const collidingAliases = new Set([...aliasHandles].filter(([, handles]) => handles.size > 1).map(([alias]) => alias));
|
|
2195
|
+
refreshedHosts.add(adapter.identity.hostId);
|
|
2196
|
+
if (!snapshot.complete)
|
|
2197
|
+
for (const alias of this.collidingClaudeAliases)
|
|
2198
|
+
if (alias.endsWith(`@${adapter.identity.hostId}`))
|
|
2199
|
+
collidingAliases.add(alias);
|
|
2200
|
+
for (const alias of collidingAliases)
|
|
2201
|
+
nextCollisions.add(alias);
|
|
2202
|
+
for (const peer of peers) {
|
|
2257
2203
|
const key = `${adapter.identity.hostId}\0${peer.routeHandle}`;
|
|
2258
2204
|
seen.add(key);
|
|
2259
2205
|
this.candidates.set(key, { ...peer, adapter, observedAt });
|
|
@@ -2264,39 +2210,42 @@ export class GatewayService {
|
|
|
2264
2210
|
runtime.health = snapshot.complete ? "healthy" : "degraded";
|
|
2265
2211
|
const registry = snapshot.registry ?? adapter.latestRegistryObservation?.();
|
|
2266
2212
|
if (registry !== undefined) {
|
|
2267
|
-
runtime.registry = publicRegistry(registry,
|
|
2213
|
+
runtime.registry = publicRegistry({ ...registry, rejected: [
|
|
2214
|
+
...registry.rejected,
|
|
2215
|
+
...(collidingAliases.size === 0 ? [] : [{ safeErrorCode: "PEER_ALIAS_COLLISION",
|
|
2216
|
+
count: collidingAliases.size }]),
|
|
2217
|
+
] }, runtime.registry?.parseableRecordSeenSinceBoot ?? false);
|
|
2268
2218
|
}
|
|
2269
2219
|
}
|
|
2270
2220
|
}
|
|
2271
2221
|
for (const [key] of this.candidates)
|
|
2272
2222
|
if (!seen.has(key))
|
|
2273
2223
|
this.candidates.delete(key);
|
|
2224
|
+
for (const alias of this.collidingClaudeAliases)
|
|
2225
|
+
if (refreshedHosts.has(aliasHost(alias)))
|
|
2226
|
+
this.collidingClaudeAliases.delete(alias);
|
|
2227
|
+
const retainedCollisions = new Set([...nextCollisions].slice(0, gatewayPublicSnapshotLimits.availablePeers));
|
|
2228
|
+
// Discovery currently caps rows such that collisions cannot overflow this independent public bound.
|
|
2229
|
+
// If those constants diverge, discard overflow candidates rather than expose an unfenced alias.
|
|
2230
|
+
for (const [key, candidate] of this.candidates)
|
|
2231
|
+
if (nextCollisions.has(candidate.alias) && !retainedCollisions.has(candidate.alias))
|
|
2232
|
+
this.candidates.delete(key);
|
|
2233
|
+
for (const alias of retainedCollisions)
|
|
2234
|
+
this.collidingClaudeAliases.add(alias);
|
|
2274
2235
|
}
|
|
2275
|
-
async
|
|
2276
|
-
if (this.
|
|
2236
|
+
async refreshManagedCodexSocket() {
|
|
2237
|
+
if (this.managedCodexSocketHeld === undefined)
|
|
2277
2238
|
return;
|
|
2278
|
-
this.
|
|
2239
|
+
this.managedCodexSocketHeldOutside = await this.managedCodexSocketHeld();
|
|
2279
2240
|
}
|
|
2280
2241
|
assertWritable() {
|
|
2281
2242
|
if (this.closing || !this.running) {
|
|
2282
2243
|
throw new BridgeError("GATEWAY_CLOSING", "The gateway is not accepting mutations.");
|
|
2283
2244
|
}
|
|
2284
2245
|
}
|
|
2285
|
-
async resolveClaudeSelector(selector, refresh) {
|
|
2286
|
-
if (refresh)
|
|
2287
|
-
await this.refreshClaudeDiscovery().catch(() => undefined);
|
|
2288
|
-
return [...this.candidates.values()].find((candidate) => candidate.alias === selector || candidate.routeHandle === selector);
|
|
2289
|
-
}
|
|
2290
|
-
async resolveSelectedClaudeRoute(selector) {
|
|
2291
|
-
return (await this.store.listLogicalRoutes()).find((route) => route.binding.provider === "claude" &&
|
|
2292
|
-
(route.alias === selector || route.binding.routeHandle === selector));
|
|
2293
|
-
}
|
|
2294
2246
|
routeObservationStillCurrent(event) {
|
|
2295
2247
|
return this.connectors.has(connectorKey(event.route));
|
|
2296
2248
|
}
|
|
2297
|
-
async publish() {
|
|
2298
|
-
await this.publishDashboard(this.store.rootDir, await this.snapshot());
|
|
2299
|
-
}
|
|
2300
2249
|
alert(code, route, error) {
|
|
2301
2250
|
const errorCode = error instanceof BridgeError ? error.code : undefined;
|
|
2302
2251
|
this.runtimeAlerts.push({
|
|
@@ -2315,7 +2264,7 @@ export class GatewayService {
|
|
|
2315
2264
|
recordPeerFailure(host, code) {
|
|
2316
2265
|
for (let index = this.runtimeAlerts.length - 1; index >= 0; index -= 1) {
|
|
2317
2266
|
const alert = this.runtimeAlerts[index];
|
|
2318
|
-
if (alert?.host === host && (alert.code
|
|
2267
|
+
if (alert?.host === host && PEER_FAILURE_CODES.includes(alert.code))
|
|
2319
2268
|
this.runtimeAlerts.splice(index, 1);
|
|
2320
2269
|
}
|
|
2321
2270
|
if (code !== undefined)
|