@ctliz/agent-intercom-claude 0.12.0-connect.3
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/.claude-plugin/plugin.json +26 -0
- package/.mcp.json +10 -0
- package/CHANGELOG.md +62 -0
- package/COPYRIGHT +6 -0
- package/LICENSE +661 -0
- package/LICENSE_TRANSITION.md +25 -0
- package/README.md +506 -0
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/broker/access-credential.ts +76 -0
- package/broker/access-registry.ts +460 -0
- package/broker/audit.ts +60 -0
- package/broker/authorization.ts +125 -0
- package/broker/boss-contracts.ts +267 -0
- package/broker/broker.ts +2028 -0
- package/broker/client.ts +1048 -0
- package/broker/framing.ts +87 -0
- package/broker/ownership.ts +61 -0
- package/broker/paths.ts +161 -0
- package/broker/spawn.ts +463 -0
- package/broker/validation.ts +132 -0
- package/claude/cci.ts +512 -0
- package/claude/ccim.ts +31 -0
- package/claude/cli-runner.ts +220 -0
- package/claude/contact.ts +33 -0
- package/claude/inbox-monitor.ts +49 -0
- package/claude/inbox.ts +77 -0
- package/claude/mcp-protocol.ts +253 -0
- package/claude/native-bridge.ts +219 -0
- package/claude/native-protocol.ts +346 -0
- package/claude/permission-policy.ts +287 -0
- package/claude/runtime.ts +510 -0
- package/claude/server.ts +69 -0
- package/claude/team.ts +348 -0
- package/claude/transport.ts +97 -0
- package/claude/worker-config.ts +198 -0
- package/claude/worker-daemon.ts +418 -0
- package/commands/intercom-id.md +9 -0
- package/commands/intercom.md +8 -0
- package/config.ts +195 -0
- package/dist/broker.mjs +2530 -0
- package/dist/cci.mjs +3607 -0
- package/dist/ccim.mjs +3621 -0
- package/dist/claude-server.mjs +2730 -0
- package/dist/inbox-monitor.mjs +68 -0
- package/dist/worker-daemon.mjs +2797 -0
- package/durable-json.ts +25 -0
- package/licenses/MIT-pi-claude-link.txt +21 -0
- package/licenses/MIT-pi-intercom.txt +21 -0
- package/monitors/monitors.json +8 -0
- package/outbound-outbox.ts +116 -0
- package/package.json +93 -0
- package/protocol-v4/contract.ts +45 -0
- package/provider/protected-service.ts +162 -0
- package/provider/provider.mjs +34 -0
- package/scripts/build.mjs +71 -0
- package/scripts/verify-core-provenance.mjs +64 -0
- package/scripts/verify-packed-runtime.mjs +140 -0
- package/skills/claude-intercom/SKILL.md +90 -0
- package/types.ts +212 -0
package/broker/broker.ts
ADDED
|
@@ -0,0 +1,2028 @@
|
|
|
1
|
+
import net from "net";
|
|
2
|
+
import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { randomUUID } from "crypto";
|
|
5
|
+
import { authorize, POLICY_SEMANTICS_HASH, POLICY_SEMANTICS_VERSION, type PolicyState } from "@ctliz/agent-intercom-core";
|
|
6
|
+
import { INTERCOM_REGISTRATION_METADATA_ERROR, parseIntercomScopeIdForRegistration, sameIntercomScope } from "../protocol-v4/contract.ts";
|
|
7
|
+
import type { BossControlKind } from "@ctliz/agent-intercom-core/boss";
|
|
8
|
+
import { writeMessage, createMessageReader } from "./framing.ts";
|
|
9
|
+
import {
|
|
10
|
+
ensureIntercomRuntimeDir,
|
|
11
|
+
getBrokerAccessStateFilePath,
|
|
12
|
+
getBrokerAdminCredentialFilePath,
|
|
13
|
+
getBrokerAskStateFilePath,
|
|
14
|
+
getBrokerAuditFilePath,
|
|
15
|
+
getBrokerListenTarget,
|
|
16
|
+
getBrokerPortFilePath,
|
|
17
|
+
getIntercomDirPath,
|
|
18
|
+
getRemoteGatewaySocketPath,
|
|
19
|
+
INTERCOM_PROTOCOL_NAME,
|
|
20
|
+
INTERCOM_PROTOCOL_VERSION,
|
|
21
|
+
INTERCOM_RUNTIME_FILE_MODE,
|
|
22
|
+
restrictIntercomRuntimeFile,
|
|
23
|
+
type BrokerConnectTarget,
|
|
24
|
+
} from "./paths.ts";
|
|
25
|
+
import { getAskTimeoutMs } from "../config.ts";
|
|
26
|
+
import { writeDurableJson } from "../durable-json.ts";
|
|
27
|
+
import { acquireBrokerOwnership, hasBrokerOwnership, releaseBrokerOwnership } from "./ownership.ts";
|
|
28
|
+
import { RemoteAccessRegistry, type RemotePrincipalMetadata, type RemotePrincipalRecord } from "./access-registry.ts";
|
|
29
|
+
import {
|
|
30
|
+
authorizeSessionAction,
|
|
31
|
+
visibleSessions,
|
|
32
|
+
type SessionAuthorizationContext,
|
|
33
|
+
type SessionPolicyAction,
|
|
34
|
+
} from "./authorization.ts";
|
|
35
|
+
import {
|
|
36
|
+
bossControlDispatchDenial,
|
|
37
|
+
parseBossRegistrationRequest,
|
|
38
|
+
parseCorrelatedBossControl,
|
|
39
|
+
} from "./boss-contracts.ts";
|
|
40
|
+
import { BrokerAuditLog } from "./audit.ts";
|
|
41
|
+
import { hasExactDataKeys, isDenseArrayOf, isPlainDataRecord } from "./validation.ts";
|
|
42
|
+
import type {
|
|
43
|
+
AskCancellationReason,
|
|
44
|
+
BrokerErrorCode,
|
|
45
|
+
BrokerMessage,
|
|
46
|
+
DeliveryFailureCode,
|
|
47
|
+
Message,
|
|
48
|
+
Attachment,
|
|
49
|
+
SessionInfo,
|
|
50
|
+
SessionRegistration,
|
|
51
|
+
RemotePrincipalSummary,
|
|
52
|
+
} from "../types.ts";
|
|
53
|
+
|
|
54
|
+
const INTERCOM_DIR = getIntercomDirPath();
|
|
55
|
+
const LISTEN_TARGET = getBrokerListenTarget();
|
|
56
|
+
const REMOTE_LISTEN_TARGET = getRemoteGatewaySocketPath();
|
|
57
|
+
const PID_PATH = join(INTERCOM_DIR, "broker.pid");
|
|
58
|
+
const OWNER_PATH = join(INTERCOM_DIR, "broker.owner");
|
|
59
|
+
const PORT_PATH = getBrokerPortFilePath(INTERCOM_DIR);
|
|
60
|
+
const ASK_STATE_PATH = getBrokerAskStateFilePath(INTERCOM_DIR);
|
|
61
|
+
const ACCESS_STATE_PATH = getBrokerAccessStateFilePath(INTERCOM_DIR);
|
|
62
|
+
const ADMIN_CREDENTIAL_PATH = getBrokerAdminCredentialFilePath(INTERCOM_DIR);
|
|
63
|
+
const AUDIT_PATH = getBrokerAuditFilePath(INTERCOM_DIR);
|
|
64
|
+
const BROKER_STATE_ID = randomUUID();
|
|
65
|
+
const MAX_SESSIONS = 128;
|
|
66
|
+
const MAX_UNREGISTERED_CONNECTIONS = 32;
|
|
67
|
+
const REGISTRATION_TIMEOUT_MS = 1000;
|
|
68
|
+
const RATE_LIMIT_CAPACITY = 240;
|
|
69
|
+
const RATE_LIMIT_REFILL_PER_SECOND = 120;
|
|
70
|
+
const REMOTE_RATE_LIMIT_CAPACITY = 60;
|
|
71
|
+
const REMOTE_RATE_LIMIT_REFILL_PER_SECOND = 30;
|
|
72
|
+
const REMOTE_EXPIRY_SWEEP_MS = Math.max(50, Number.parseInt(process.env.PI_INTERCOM_REMOTE_EXPIRY_SWEEP_MS ?? "1000", 10) || 1000);
|
|
73
|
+
const PRESENCE_HEARTBEAT_MS = 1000;
|
|
74
|
+
const DELIVERY_ACK_TIMEOUT_MS = 8000;
|
|
75
|
+
const RECENT_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
|
76
|
+
const MAX_PENDING_DELIVERIES = 1024;
|
|
77
|
+
const MAX_PENDING_DELIVERIES_PER_SESSION = 64;
|
|
78
|
+
const MAX_PENDING_ASKS_PER_SESSION = 64;
|
|
79
|
+
const RATE_LIMIT_BYTES_PER_TOKEN = 8 * 1024;
|
|
80
|
+
const MAX_MESSAGE_TEXT_BYTES = 256 * 1024;
|
|
81
|
+
const MAX_ATTACHMENT_CONTENT_BYTES = 512 * 1024;
|
|
82
|
+
const MAX_ATTACHMENTS = 16;
|
|
83
|
+
const MAX_MESSAGE_ID_LENGTH = 256;
|
|
84
|
+
const MAX_TARGET_LENGTH = 512;
|
|
85
|
+
const MAX_SESSION_NAME_LENGTH = 256;
|
|
86
|
+
const MAX_SESSION_CWD_LENGTH = 4096;
|
|
87
|
+
const MAX_SESSION_MODEL_LENGTH = 512;
|
|
88
|
+
const MAX_SESSION_STATUS_LENGTH = 512;
|
|
89
|
+
const MAX_RUNTIME_INSTANCE_ID_LENGTH = 256;
|
|
90
|
+
|
|
91
|
+
interface ConnectedSession {
|
|
92
|
+
socket: net.Socket;
|
|
93
|
+
info: SessionInfo;
|
|
94
|
+
scopeId?: string;
|
|
95
|
+
runtimeInstanceId?: string;
|
|
96
|
+
lastPresenceBroadcastAt: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type ConnectionOrigin = "local" | "remote";
|
|
100
|
+
|
|
101
|
+
interface ConnectionState {
|
|
102
|
+
socket: net.Socket;
|
|
103
|
+
origin: ConnectionOrigin;
|
|
104
|
+
tokens: number;
|
|
105
|
+
refillPerSecond: number;
|
|
106
|
+
lastRefillAt: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface AskEdge {
|
|
110
|
+
messageId: string;
|
|
111
|
+
from: string;
|
|
112
|
+
to: string;
|
|
113
|
+
createdAt: number;
|
|
114
|
+
expiresAt: number;
|
|
115
|
+
state: "blocking" | "deferred";
|
|
116
|
+
timeout: NodeJS.Timeout;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface PersistedAskEdge {
|
|
120
|
+
messageId: string;
|
|
121
|
+
from: string;
|
|
122
|
+
to: string;
|
|
123
|
+
createdAt: number;
|
|
124
|
+
expiresAt: number;
|
|
125
|
+
state: "blocking" | "deferred";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface PendingDelivery {
|
|
129
|
+
id: string;
|
|
130
|
+
key: string;
|
|
131
|
+
fingerprint: string;
|
|
132
|
+
message: Message;
|
|
133
|
+
from: string;
|
|
134
|
+
to: string;
|
|
135
|
+
senderSocket: net.Socket;
|
|
136
|
+
recipientSocket: net.Socket;
|
|
137
|
+
action: SessionPolicyAction;
|
|
138
|
+
controlKind?: BossControlKind;
|
|
139
|
+
fromGeneration: number;
|
|
140
|
+
toGeneration: number;
|
|
141
|
+
timeout: NodeJS.Timeout;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface RecentDelivery {
|
|
145
|
+
fingerprint: string;
|
|
146
|
+
from: string;
|
|
147
|
+
to: string;
|
|
148
|
+
action: SessionPolicyAction;
|
|
149
|
+
controlKind?: BossControlKind;
|
|
150
|
+
fromGeneration: number;
|
|
151
|
+
toGeneration: number;
|
|
152
|
+
retryable: boolean;
|
|
153
|
+
response:
|
|
154
|
+
| { type: "delivered"; messageId: string; deliveryId: string }
|
|
155
|
+
| { type: "delivery_failed"; messageId: string; accepted: boolean; code: DeliveryFailureCode; reason: string };
|
|
156
|
+
expiresAt: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isAttachment(value: unknown): value is Attachment {
|
|
160
|
+
if (!hasExactDataKeys(value, ["type", "name", "content"], ["language"])) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const attachment = value as Record<string, unknown>;
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
attachment.type !== "file"
|
|
168
|
+
&& attachment.type !== "snippet"
|
|
169
|
+
&& attachment.type !== "context"
|
|
170
|
+
) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
typeof attachment.name !== "string"
|
|
176
|
+
|| attachment.name.length > 256
|
|
177
|
+
|| typeof attachment.content !== "string"
|
|
178
|
+
|| Buffer.byteLength(attachment.content, "utf-8") > MAX_ATTACHMENT_CONTENT_BYTES
|
|
179
|
+
) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return attachment.language === undefined || typeof attachment.language === "string";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isMessage(value: unknown): value is Message {
|
|
187
|
+
if (!hasExactDataKeys(value, ["id", "timestamp", "content"], ["replyTo", "expectsReply", "control"])) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const message = value as Record<string, unknown>;
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
typeof message.id !== "string"
|
|
195
|
+
|| message.id.length === 0
|
|
196
|
+
|| message.id.length > MAX_MESSAGE_ID_LENGTH
|
|
197
|
+
|| typeof message.timestamp !== "number"
|
|
198
|
+
|| !Number.isFinite(message.timestamp)
|
|
199
|
+
) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (
|
|
204
|
+
message.replyTo !== undefined
|
|
205
|
+
&& (typeof message.replyTo !== "string" || message.replyTo.length === 0 || message.replyTo.length > MAX_MESSAGE_ID_LENGTH)
|
|
206
|
+
) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (message.expectsReply !== undefined && typeof message.expectsReply !== "boolean") {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (message.control !== undefined) {
|
|
215
|
+
try {
|
|
216
|
+
parseCorrelatedBossControl(message.control, message.id);
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
if (message.replyTo !== undefined || message.expectsReply !== undefined) return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!hasExactDataKeys(message.content, ["text"], ["attachments"])) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const content = message.content as Record<string, unknown>;
|
|
228
|
+
if (typeof content.text !== "string" || Buffer.byteLength(content.text, "utf-8") > MAX_MESSAGE_TEXT_BYTES) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (message.control !== undefined && (content.text !== "" || content.attachments !== undefined)) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return content.attachments === undefined
|
|
237
|
+
|| (
|
|
238
|
+
isDenseArrayOf(content.attachments, isAttachment)
|
|
239
|
+
&& content.attachments.length <= MAX_ATTACHMENTS
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isSessionId(value: unknown): value is string {
|
|
244
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isSessionRegistration(value: unknown): value is SessionRegistration {
|
|
248
|
+
if (!hasExactDataKeys(
|
|
249
|
+
value,
|
|
250
|
+
["cwd", "model", "pid", "startedAt", "lastActivity"],
|
|
251
|
+
["name", "status", "runtimeInstanceId", "boss"],
|
|
252
|
+
)) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const session = value as Record<string, unknown>;
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
typeof session.cwd !== "string"
|
|
260
|
+
|| session.cwd.length === 0
|
|
261
|
+
|| session.cwd.length > MAX_SESSION_CWD_LENGTH
|
|
262
|
+
|| typeof session.model !== "string"
|
|
263
|
+
|| session.model.length === 0
|
|
264
|
+
|| session.model.length > MAX_SESSION_MODEL_LENGTH
|
|
265
|
+
|| typeof session.pid !== "number"
|
|
266
|
+
|| !Number.isFinite(session.pid)
|
|
267
|
+
|| typeof session.startedAt !== "number"
|
|
268
|
+
|| !Number.isFinite(session.startedAt)
|
|
269
|
+
|| typeof session.lastActivity !== "number"
|
|
270
|
+
|| !Number.isFinite(session.lastActivity)
|
|
271
|
+
) {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (session.name !== undefined && (typeof session.name !== "string" || session.name.length > MAX_SESSION_NAME_LENGTH)) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
if (
|
|
279
|
+
session.runtimeInstanceId !== undefined
|
|
280
|
+
&& (
|
|
281
|
+
typeof session.runtimeInstanceId !== "string"
|
|
282
|
+
|| session.runtimeInstanceId.length === 0
|
|
283
|
+
|| session.runtimeInstanceId.length > MAX_RUNTIME_INSTANCE_ID_LENGTH
|
|
284
|
+
)
|
|
285
|
+
) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (session.boss !== undefined) {
|
|
290
|
+
try {
|
|
291
|
+
parseBossRegistrationRequest(session.boss);
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return session.status === undefined
|
|
298
|
+
|| (typeof session.status === "string" && session.status.length <= MAX_SESSION_STATUS_LENGTH);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isSameLocalRuntime(previous: ConnectedSession, registration: SessionRegistration): boolean {
|
|
302
|
+
if (previous.runtimeInstanceId !== undefined || registration.runtimeInstanceId !== undefined) {
|
|
303
|
+
return previous.runtimeInstanceId !== undefined
|
|
304
|
+
&& previous.runtimeInstanceId === registration.runtimeInstanceId;
|
|
305
|
+
}
|
|
306
|
+
return previous.info.pid === registration.pid
|
|
307
|
+
&& previous.info.startedAt === registration.startedAt;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
class IntercomBroker {
|
|
311
|
+
private sessions = new Map<string, ConnectedSession>();
|
|
312
|
+
private askEdges = new Map<string, AskEdge>();
|
|
313
|
+
private pendingDeliveries = new Map<string, PendingDelivery>();
|
|
314
|
+
private pendingDeliveryKeys = new Map<string, string>();
|
|
315
|
+
private recentDeliveries = new Map<string, RecentDelivery>();
|
|
316
|
+
private connections = new Set<net.Socket>();
|
|
317
|
+
private unregisteredConnections = new Set<net.Socket>();
|
|
318
|
+
private server: net.Server;
|
|
319
|
+
private remoteServer: net.Server | null = null;
|
|
320
|
+
private shutdownTimer: NodeJS.Timeout | null = null;
|
|
321
|
+
private expiryTimer: NodeJS.Timeout | null = null;
|
|
322
|
+
private readonly askTimeoutMs = getAskTimeoutMs();
|
|
323
|
+
private readonly accessRegistry: RemoteAccessRegistry;
|
|
324
|
+
private readonly audit: BrokerAuditLog;
|
|
325
|
+
|
|
326
|
+
constructor() {
|
|
327
|
+
ensureIntercomRuntimeDir(INTERCOM_DIR);
|
|
328
|
+
acquireBrokerOwnership(OWNER_PATH);
|
|
329
|
+
this.accessRegistry = new RemoteAccessRegistry(ACCESS_STATE_PATH);
|
|
330
|
+
this.audit = new BrokerAuditLog(AUDIT_PATH);
|
|
331
|
+
this.accessRegistry.ensureAdminCredential(ADMIN_CREDENTIAL_PATH);
|
|
332
|
+
this.loadAskEdges();
|
|
333
|
+
if (typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
|
|
334
|
+
for (const socketPath of [LISTEN_TARGET, REMOTE_LISTEN_TARGET]) {
|
|
335
|
+
try {
|
|
336
|
+
unlinkSync(socketPath);
|
|
337
|
+
} catch {
|
|
338
|
+
// A clean startup has no stale socket to remove.
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
this.server = net.createServer((socket) => this.handleConnection(socket, "local"));
|
|
343
|
+
if (process.platform !== "win32" && typeof LISTEN_TARGET === "string") {
|
|
344
|
+
this.remoteServer = net.createServer((socket) => this.handleConnection(socket, "remote"));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
start(): void {
|
|
349
|
+
let localListening = false;
|
|
350
|
+
let remoteListening = this.remoteServer === null;
|
|
351
|
+
const announceWhenReady = () => {
|
|
352
|
+
if (!localListening || !remoteListening) return;
|
|
353
|
+
writeFileSync(PID_PATH, String(process.pid), { mode: INTERCOM_RUNTIME_FILE_MODE });
|
|
354
|
+
restrictIntercomRuntimeFile(PID_PATH);
|
|
355
|
+
console.log(`Intercom broker started (pid: ${process.pid}, remote-access-v1)`);
|
|
356
|
+
};
|
|
357
|
+
const onLocalListening = () => {
|
|
358
|
+
if (typeof LISTEN_TARGET === "string") {
|
|
359
|
+
restrictIntercomRuntimeFile(LISTEN_TARGET);
|
|
360
|
+
} else {
|
|
361
|
+
const address = this.server.address();
|
|
362
|
+
if (!address || typeof address === "string") {
|
|
363
|
+
throw new Error("Intercom TCP broker started without a TCP address");
|
|
364
|
+
}
|
|
365
|
+
const endpoint: BrokerConnectTarget = {
|
|
366
|
+
transport: "tcp",
|
|
367
|
+
host: LISTEN_TARGET.host,
|
|
368
|
+
port: address.port,
|
|
369
|
+
stateId: BROKER_STATE_ID,
|
|
370
|
+
};
|
|
371
|
+
writeFileSync(PORT_PATH, `${JSON.stringify(endpoint)}\n`, { mode: INTERCOM_RUNTIME_FILE_MODE });
|
|
372
|
+
restrictIntercomRuntimeFile(PORT_PATH);
|
|
373
|
+
}
|
|
374
|
+
localListening = true;
|
|
375
|
+
announceWhenReady();
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
if (typeof LISTEN_TARGET === "string") {
|
|
379
|
+
this.server.listen(LISTEN_TARGET, onLocalListening);
|
|
380
|
+
} else {
|
|
381
|
+
this.server.listen({ host: LISTEN_TARGET.host, port: LISTEN_TARGET.port }, onLocalListening);
|
|
382
|
+
}
|
|
383
|
+
if (this.remoteServer) {
|
|
384
|
+
this.remoteServer.listen(REMOTE_LISTEN_TARGET, () => {
|
|
385
|
+
restrictIntercomRuntimeFile(REMOTE_LISTEN_TARGET);
|
|
386
|
+
remoteListening = true;
|
|
387
|
+
announceWhenReady();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
this.expiryTimer = setInterval(() => this.reconcileExpiredPrincipals(), REMOTE_EXPIRY_SWEEP_MS);
|
|
391
|
+
this.expiryTimer.unref?.();
|
|
392
|
+
process.on("SIGTERM", () => this.shutdown());
|
|
393
|
+
process.on("SIGINT", () => this.shutdown());
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private handleConnection(socket: net.Socket, origin: ConnectionOrigin): void {
|
|
397
|
+
this.connections.add(socket);
|
|
398
|
+
let sessionId: string | null = null;
|
|
399
|
+
let registrationTimeout: NodeJS.Timeout | null = null;
|
|
400
|
+
const armRegistrationTimeout = () => {
|
|
401
|
+
if (registrationTimeout) {
|
|
402
|
+
clearTimeout(registrationTimeout);
|
|
403
|
+
}
|
|
404
|
+
this.unregisteredConnections.delete(socket);
|
|
405
|
+
this.unregisteredConnections.add(socket);
|
|
406
|
+
this.evictOldestUnregisteredConnections(socket);
|
|
407
|
+
registrationTimeout = setTimeout(() => {
|
|
408
|
+
if (!sessionId) {
|
|
409
|
+
socket.destroy();
|
|
410
|
+
}
|
|
411
|
+
}, REGISTRATION_TIMEOUT_MS);
|
|
412
|
+
registrationTimeout.unref?.();
|
|
413
|
+
};
|
|
414
|
+
const clearRegistrationTimeout = () => {
|
|
415
|
+
if (registrationTimeout) {
|
|
416
|
+
clearTimeout(registrationTimeout);
|
|
417
|
+
registrationTimeout = null;
|
|
418
|
+
}
|
|
419
|
+
this.unregisteredConnections.delete(socket);
|
|
420
|
+
};
|
|
421
|
+
armRegistrationTimeout();
|
|
422
|
+
const connection: ConnectionState = {
|
|
423
|
+
socket,
|
|
424
|
+
origin,
|
|
425
|
+
tokens: origin === "remote" ? REMOTE_RATE_LIMIT_CAPACITY : RATE_LIMIT_CAPACITY,
|
|
426
|
+
refillPerSecond: origin === "remote" ? REMOTE_RATE_LIMIT_REFILL_PER_SECOND : RATE_LIMIT_REFILL_PER_SECOND,
|
|
427
|
+
lastRefillAt: Date.now(),
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const reader = createMessageReader((msg) => {
|
|
431
|
+
const byteCost = Math.max(1, Math.ceil(Buffer.byteLength(JSON.stringify(msg), "utf-8") / RATE_LIMIT_BYTES_PER_TOKEN));
|
|
432
|
+
if (!this.consumeToken(connection, byteCost)) {
|
|
433
|
+
this.sendError(socket, "RATE_LIMITED", "Intercom broker rate limit exceeded");
|
|
434
|
+
socket.destroy(new Error("Intercom broker rate limit exceeded"));
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
this.handleMessage(socket, origin, msg, sessionId, (id) => {
|
|
439
|
+
sessionId = id;
|
|
440
|
+
if (id) {
|
|
441
|
+
clearRegistrationTimeout();
|
|
442
|
+
} else {
|
|
443
|
+
armRegistrationTimeout();
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
} catch (error) {
|
|
447
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
448
|
+
if (reason === "Invalid intercom TCP endpoint credentials") {
|
|
449
|
+
socket.destroy();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.sendError(socket, "INVALID_REQUEST", reason);
|
|
453
|
+
socket.end();
|
|
454
|
+
}
|
|
455
|
+
}, (error) => {
|
|
456
|
+
socket.destroy(error);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
socket.on("data", reader);
|
|
460
|
+
|
|
461
|
+
socket.on("close", () => {
|
|
462
|
+
clearRegistrationTimeout();
|
|
463
|
+
this.connections.delete(socket);
|
|
464
|
+
if (sessionId) {
|
|
465
|
+
const existing = this.sessions.get(sessionId);
|
|
466
|
+
if (existing?.socket === socket) {
|
|
467
|
+
if (existing.info.origin === "remote") {
|
|
468
|
+
this.audit.tryRecord({
|
|
469
|
+
event: "remote_disconnect",
|
|
470
|
+
outcome: "observed",
|
|
471
|
+
actorId: sessionId,
|
|
472
|
+
remoteHostId: existing.info.remoteHostId,
|
|
473
|
+
generation: existing.info.generation,
|
|
474
|
+
reason: "SOCKET_CLOSED",
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
this.broadcastLifecycle({ type: "session_left", sessionId }, existing, sessionId);
|
|
478
|
+
this.sessions.delete(sessionId);
|
|
479
|
+
this.clearPendingDeliveriesForSession(sessionId, socket);
|
|
480
|
+
this.deferAskEdgesForSession(sessionId);
|
|
481
|
+
this.scheduleShutdownCheck();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
socket.on("error", (error) => {
|
|
487
|
+
console.error("Socket error:", error);
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private evictOldestUnregisteredConnections(currentSocket: net.Socket): void {
|
|
492
|
+
while (this.unregisteredConnections.size > MAX_UNREGISTERED_CONNECTIONS) {
|
|
493
|
+
const [oldest] = this.unregisteredConnections;
|
|
494
|
+
if (!oldest) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (oldest === currentSocket && this.unregisteredConnections.size === 1) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
this.unregisteredConnections.delete(oldest);
|
|
501
|
+
oldest.destroy();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private consumeToken(connection: ConnectionState, cost = 1, now = Date.now()): boolean {
|
|
506
|
+
const elapsedMs = now - connection.lastRefillAt;
|
|
507
|
+
if (elapsedMs > 0) {
|
|
508
|
+
connection.tokens = Math.min(
|
|
509
|
+
connection.origin === "remote" ? REMOTE_RATE_LIMIT_CAPACITY : RATE_LIMIT_CAPACITY,
|
|
510
|
+
connection.tokens + elapsedMs * connection.refillPerSecond / 1000,
|
|
511
|
+
);
|
|
512
|
+
connection.lastRefillAt = now;
|
|
513
|
+
}
|
|
514
|
+
if (connection.tokens < cost) {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
connection.tokens -= cost;
|
|
518
|
+
return true;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private sendError(socket: net.Socket, code: BrokerErrorCode, error: string): void {
|
|
522
|
+
writeMessage(socket, { type: "error", code, error });
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private sendDeliveryFailure(
|
|
526
|
+
socket: net.Socket,
|
|
527
|
+
messageId: string,
|
|
528
|
+
accepted: boolean,
|
|
529
|
+
code: DeliveryFailureCode,
|
|
530
|
+
reason: string,
|
|
531
|
+
): void {
|
|
532
|
+
writeMessage(socket, { type: "delivery_failed", messageId, accepted, code, reason });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private scheduleShutdownCheck(): void {
|
|
536
|
+
if (this.shutdownTimer) return;
|
|
537
|
+
|
|
538
|
+
this.shutdownTimer = setTimeout(() => {
|
|
539
|
+
this.shutdownTimer = null;
|
|
540
|
+
if (this.sessions.size === 0) {
|
|
541
|
+
console.log("No sessions connected, shutting down");
|
|
542
|
+
this.shutdown();
|
|
543
|
+
}
|
|
544
|
+
}, 5000);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private handleMessage(
|
|
548
|
+
socket: net.Socket,
|
|
549
|
+
origin: ConnectionOrigin,
|
|
550
|
+
msg: unknown,
|
|
551
|
+
currentId: string | null,
|
|
552
|
+
setId: (id: string | null) => void,
|
|
553
|
+
): void {
|
|
554
|
+
if (!isPlainDataRecord(msg) || typeof msg.type !== "string") {
|
|
555
|
+
throw new Error("Invalid client message");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const clientMessage = msg as { type: string } & Record<string, unknown>;
|
|
559
|
+
const requiresEndpointAuth = typeof LISTEN_TARGET !== "string";
|
|
560
|
+
const hasEndpointAuth = clientMessage.stateId === BROKER_STATE_ID;
|
|
561
|
+
|
|
562
|
+
if (clientMessage.type === "health") {
|
|
563
|
+
if (
|
|
564
|
+
!hasExactDataKeys(clientMessage, ["type", "requestId"], ["stateId"])
|
|
565
|
+
|| typeof clientMessage.requestId !== "string"
|
|
566
|
+
) {
|
|
567
|
+
throw new Error("Invalid health message");
|
|
568
|
+
}
|
|
569
|
+
if (requiresEndpointAuth && !hasEndpointAuth) {
|
|
570
|
+
throw new Error("Invalid intercom TCP endpoint credentials");
|
|
571
|
+
}
|
|
572
|
+
writeMessage(socket, {
|
|
573
|
+
type: "health_ok",
|
|
574
|
+
requestId: clientMessage.requestId,
|
|
575
|
+
protocol: INTERCOM_PROTOCOL_NAME,
|
|
576
|
+
version: INTERCOM_PROTOCOL_VERSION,
|
|
577
|
+
endpoint: origin,
|
|
578
|
+
remoteAccess: this.remoteAccessContract(),
|
|
579
|
+
});
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (clientMessage.type === "access_control") {
|
|
584
|
+
if (currentId !== null) {
|
|
585
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote access control requires a short-lived control connection");
|
|
586
|
+
socket.end();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (origin === "local") this.handleAccessControl(socket, clientMessage);
|
|
590
|
+
else this.handleRemoteAccessControl(socket, clientMessage);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (requiresEndpointAuth && clientMessage.type === "register" && !hasEndpointAuth) {
|
|
595
|
+
throw new Error("Invalid intercom TCP endpoint credentials");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (currentId === null && clientMessage.type !== "register") {
|
|
599
|
+
throw new Error(`Received ${clientMessage.type} before register`);
|
|
600
|
+
}
|
|
601
|
+
if (currentId) {
|
|
602
|
+
const bound = this.sessions.get(currentId);
|
|
603
|
+
if (bound?.socket !== socket) {
|
|
604
|
+
socket.destroy();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (!this.isCurrentPrincipal(currentId)) {
|
|
608
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote session authorization is no longer valid");
|
|
609
|
+
socket.destroy();
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
switch (clientMessage.type) {
|
|
615
|
+
case "register": {
|
|
616
|
+
if (
|
|
617
|
+
!hasExactDataKeys(
|
|
618
|
+
clientMessage,
|
|
619
|
+
["type", "protocol", "version", "session"],
|
|
620
|
+
origin === "remote" ? ["sessionId", "stateId", "access", "scopeId"] : ["sessionId", "stateId", "scopeId"],
|
|
621
|
+
)
|
|
622
|
+
) {
|
|
623
|
+
throw new Error("Invalid register message");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (
|
|
627
|
+
clientMessage.protocol !== INTERCOM_PROTOCOL_NAME
|
|
628
|
+
|| clientMessage.version !== INTERCOM_PROTOCOL_VERSION
|
|
629
|
+
) {
|
|
630
|
+
this.sendError(
|
|
631
|
+
socket,
|
|
632
|
+
"PROTOCOL_MISMATCH",
|
|
633
|
+
`Unsupported intercom protocol; expected ${INTERCOM_PROTOCOL_NAME} v${INTERCOM_PROTOCOL_VERSION}`,
|
|
634
|
+
);
|
|
635
|
+
socket.end();
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
let scopeId: string | undefined;
|
|
640
|
+
try {
|
|
641
|
+
scopeId = parseIntercomScopeIdForRegistration(clientMessage.scopeId);
|
|
642
|
+
} catch {
|
|
643
|
+
this.sendError(socket, "INVALID_REQUEST", INTERCOM_REGISTRATION_METADATA_ERROR);
|
|
644
|
+
socket.end();
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (!isSessionRegistration(clientMessage.session)) {
|
|
649
|
+
throw new Error("Invalid register message");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (currentId) {
|
|
653
|
+
throw new Error("Received duplicate register message");
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (clientMessage.session.boss !== undefined) {
|
|
657
|
+
this.sendError(
|
|
658
|
+
socket,
|
|
659
|
+
"BOSS_FEATURE_UNAVAILABLE",
|
|
660
|
+
"boss-run-v1 is dormant until the protected broker lockstep readiness contract is complete",
|
|
661
|
+
);
|
|
662
|
+
socket.end();
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
let id: string;
|
|
667
|
+
let remotePrincipal: RemotePrincipalRecord | undefined;
|
|
668
|
+
let issuedSessionCredential: string | undefined;
|
|
669
|
+
let enrollmentConsumed = false;
|
|
670
|
+
let previousLocal: ConnectedSession | undefined;
|
|
671
|
+
if (origin === "remote") {
|
|
672
|
+
if (this.sessions.size >= MAX_SESSIONS) {
|
|
673
|
+
this.sendError(socket, "TOO_MANY_SESSIONS", "Too many registered intercom sessions");
|
|
674
|
+
socket.destroy();
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
const access = clientMessage.access;
|
|
678
|
+
if (!isPlainDataRecord(access)) {
|
|
679
|
+
this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "MISSING_CREDENTIAL" });
|
|
680
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote registration requires an access credential");
|
|
681
|
+
socket.end();
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
const fields = access as Record<string, unknown>;
|
|
685
|
+
try {
|
|
686
|
+
if (typeof fields.enrollmentToken === "string") {
|
|
687
|
+
const consumed = this.accessRegistry.consumeEnrollment(fields.enrollmentToken);
|
|
688
|
+
remotePrincipal = consumed.principal;
|
|
689
|
+
issuedSessionCredential = consumed.sessionCredential;
|
|
690
|
+
enrollmentConsumed = true;
|
|
691
|
+
} else if (
|
|
692
|
+
typeof fields.sessionCredential === "string"
|
|
693
|
+
&& typeof fields.sessionId === "string"
|
|
694
|
+
&& typeof fields.generation === "number"
|
|
695
|
+
&& Number.isSafeInteger(fields.generation)
|
|
696
|
+
) {
|
|
697
|
+
remotePrincipal = this.accessRegistry.authenticateSession(fields.sessionId, fields.generation, fields.sessionCredential);
|
|
698
|
+
} else {
|
|
699
|
+
throw new Error("Invalid remote access credential shape");
|
|
700
|
+
}
|
|
701
|
+
} catch {
|
|
702
|
+
this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "INVALID_CREDENTIAL" });
|
|
703
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote registration credential was rejected");
|
|
704
|
+
socket.end();
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
id = remotePrincipal.id;
|
|
708
|
+
if (this.sessions.has(id)) {
|
|
709
|
+
this.audit.tryRecord({
|
|
710
|
+
event: "credential_reuse_denied",
|
|
711
|
+
outcome: "denied",
|
|
712
|
+
actorId: id,
|
|
713
|
+
remoteHostId: remotePrincipal.remoteHostId,
|
|
714
|
+
generation: remotePrincipal.generation,
|
|
715
|
+
reason: "ALREADY_ACTIVE",
|
|
716
|
+
});
|
|
717
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote session credential is already active");
|
|
718
|
+
socket.end();
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
} else {
|
|
722
|
+
id = randomUUID();
|
|
723
|
+
if (clientMessage.sessionId !== undefined) {
|
|
724
|
+
if (!isSessionId(clientMessage.sessionId)) {
|
|
725
|
+
throw new Error("Invalid register sessionId");
|
|
726
|
+
}
|
|
727
|
+
id = clientMessage.sessionId;
|
|
728
|
+
}
|
|
729
|
+
const previous = this.sessions.get(id);
|
|
730
|
+
if (!previous && this.sessions.size >= MAX_SESSIONS) {
|
|
731
|
+
this.sendError(socket, "TOO_MANY_SESSIONS", "Too many registered intercom sessions");
|
|
732
|
+
socket.destroy();
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
if (previous && !isSameLocalRuntime(previous, clientMessage.session)) {
|
|
736
|
+
this.sendError(
|
|
737
|
+
socket,
|
|
738
|
+
"SESSION_ID_IN_USE",
|
|
739
|
+
`Session ID "${id}" is already active in another local runtime; close the existing session or use a different session ID`,
|
|
740
|
+
);
|
|
741
|
+
socket.end();
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
previousLocal = previous;
|
|
745
|
+
}
|
|
746
|
+
setId(id);
|
|
747
|
+
const session = clientMessage.session;
|
|
748
|
+
const info: SessionInfo = remotePrincipal ? {
|
|
749
|
+
id,
|
|
750
|
+
name: remotePrincipal.name,
|
|
751
|
+
cwd: session.cwd,
|
|
752
|
+
model: session.model,
|
|
753
|
+
pid: session.pid,
|
|
754
|
+
startedAt: session.startedAt,
|
|
755
|
+
lastActivity: session.lastActivity,
|
|
756
|
+
...(session.status !== undefined ? { status: session.status } : {}),
|
|
757
|
+
trustedLocal: false,
|
|
758
|
+
origin: "remote",
|
|
759
|
+
remoteHostId: remotePrincipal.remoteHostId,
|
|
760
|
+
parentSessionId: remotePrincipal.parentSessionId,
|
|
761
|
+
rootSessionId: remotePrincipal.rootSessionId,
|
|
762
|
+
generation: remotePrincipal.generation,
|
|
763
|
+
canDelegate: remotePrincipal.canDelegate,
|
|
764
|
+
depth: remotePrincipal.depth,
|
|
765
|
+
maxDepth: remotePrincipal.maxDepth,
|
|
766
|
+
maxChildren: remotePrincipal.maxChildren,
|
|
767
|
+
} : {
|
|
768
|
+
id,
|
|
769
|
+
...(session.name !== undefined ? { name: session.name } : {}),
|
|
770
|
+
cwd: session.cwd,
|
|
771
|
+
model: session.model,
|
|
772
|
+
pid: session.pid,
|
|
773
|
+
startedAt: session.startedAt,
|
|
774
|
+
lastActivity: session.lastActivity,
|
|
775
|
+
...(session.status !== undefined ? { status: session.status } : {}),
|
|
776
|
+
trustedLocal: typeof LISTEN_TARGET === "string" && process.platform !== "win32",
|
|
777
|
+
origin: "local",
|
|
778
|
+
};
|
|
779
|
+
if (remotePrincipal) {
|
|
780
|
+
this.audit.record({
|
|
781
|
+
event: enrollmentConsumed ? "enrollment_consumed" : "remote_reconnect",
|
|
782
|
+
outcome: "allowed",
|
|
783
|
+
actorId: id,
|
|
784
|
+
targetId: remotePrincipal.parentSessionId,
|
|
785
|
+
remoteHostId: remotePrincipal.remoteHostId,
|
|
786
|
+
generation: remotePrincipal.generation,
|
|
787
|
+
});
|
|
788
|
+
this.audit.record({
|
|
789
|
+
event: "remote_connect",
|
|
790
|
+
outcome: "allowed",
|
|
791
|
+
actorId: id,
|
|
792
|
+
targetId: remotePrincipal.parentSessionId,
|
|
793
|
+
remoteHostId: remotePrincipal.remoteHostId,
|
|
794
|
+
generation: remotePrincipal.generation,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
const connected: ConnectedSession = {
|
|
798
|
+
socket,
|
|
799
|
+
info,
|
|
800
|
+
...(scopeId ? { scopeId } : {}),
|
|
801
|
+
...(!remotePrincipal && clientMessage.session.runtimeInstanceId
|
|
802
|
+
? { runtimeInstanceId: clientMessage.session.runtimeInstanceId }
|
|
803
|
+
: {}),
|
|
804
|
+
lastPresenceBroadcastAt: Date.now(),
|
|
805
|
+
};
|
|
806
|
+
if (previousLocal) {
|
|
807
|
+
this.broadcastLifecycle({ type: "session_left", sessionId: id }, previousLocal, id);
|
|
808
|
+
this.clearPendingDeliveriesForSession(id, previousLocal.socket);
|
|
809
|
+
this.deferAskEdgesForSession(id);
|
|
810
|
+
}
|
|
811
|
+
this.sessions.set(id, connected);
|
|
812
|
+
|
|
813
|
+
if (this.shutdownTimer) {
|
|
814
|
+
clearTimeout(this.shutdownTimer);
|
|
815
|
+
this.shutdownTimer = null;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
writeMessage(socket, {
|
|
819
|
+
type: "registered",
|
|
820
|
+
sessionId: id,
|
|
821
|
+
protocol: INTERCOM_PROTOCOL_NAME,
|
|
822
|
+
version: INTERCOM_PROTOCOL_VERSION,
|
|
823
|
+
...(remotePrincipal ? {
|
|
824
|
+
remoteAccess: this.remoteAccessContract(),
|
|
825
|
+
access: {
|
|
826
|
+
origin: "remote",
|
|
827
|
+
remoteHostId: remotePrincipal.remoteHostId,
|
|
828
|
+
parentSessionId: remotePrincipal.parentSessionId,
|
|
829
|
+
rootSessionId: remotePrincipal.rootSessionId,
|
|
830
|
+
generation: remotePrincipal.generation,
|
|
831
|
+
canDelegate: remotePrincipal.canDelegate,
|
|
832
|
+
depth: remotePrincipal.depth,
|
|
833
|
+
maxDepth: remotePrincipal.maxDepth,
|
|
834
|
+
maxChildren: remotePrincipal.maxChildren,
|
|
835
|
+
...(issuedSessionCredential ? { sessionCredential: issuedSessionCredential } : {}),
|
|
836
|
+
},
|
|
837
|
+
} : {}),
|
|
838
|
+
});
|
|
839
|
+
this.broadcastLifecycle({ type: "session_joined", session: info }, connected, id);
|
|
840
|
+
previousLocal?.socket.end();
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
case "unregister": {
|
|
845
|
+
if (!currentId) {
|
|
846
|
+
throw new Error("Received unregister before register");
|
|
847
|
+
}
|
|
848
|
+
if (clientMessage.preserveAsks !== undefined && typeof clientMessage.preserveAsks !== "boolean") {
|
|
849
|
+
throw new Error("Invalid unregister preserveAsks value");
|
|
850
|
+
}
|
|
851
|
+
const existing = this.sessions.get(currentId);
|
|
852
|
+
if (existing?.socket === socket) {
|
|
853
|
+
if (existing.info.origin === "remote") {
|
|
854
|
+
this.audit.tryRecord({
|
|
855
|
+
event: "remote_disconnect",
|
|
856
|
+
outcome: "observed",
|
|
857
|
+
actorId: currentId,
|
|
858
|
+
remoteHostId: existing.info.remoteHostId,
|
|
859
|
+
generation: existing.info.generation,
|
|
860
|
+
reason: "UNREGISTERED",
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
this.broadcastLifecycle({ type: "session_left", sessionId: currentId }, existing, currentId);
|
|
864
|
+
this.sessions.delete(currentId);
|
|
865
|
+
this.clearPendingDeliveriesForSession(currentId, socket);
|
|
866
|
+
if (clientMessage.preserveAsks) {
|
|
867
|
+
this.deferAskEdgesForSession(currentId);
|
|
868
|
+
} else {
|
|
869
|
+
this.clearAskEdgesForSession(currentId, "session_disconnected");
|
|
870
|
+
}
|
|
871
|
+
this.scheduleShutdownCheck();
|
|
872
|
+
}
|
|
873
|
+
setId(null);
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
case "list": {
|
|
878
|
+
if (typeof clientMessage.requestId !== "string") {
|
|
879
|
+
throw new Error("Invalid list message");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const allConnected = Array.from(this.sessions.values());
|
|
883
|
+
const actor = this.sessions.get(currentId!);
|
|
884
|
+
const sameScopeConnected = actor
|
|
885
|
+
? allConnected.filter((candidate) => sameIntercomScope(actor.scopeId, candidate.scopeId))
|
|
886
|
+
: [];
|
|
887
|
+
const sameScopeInfos = sameScopeConnected.map((s) => s.info);
|
|
888
|
+
const sessions = visibleSessions(sameScopeInfos, currentId!);
|
|
889
|
+
if (actor?.info.origin === "remote" && sessions.length < allConnected.length) {
|
|
890
|
+
this.audit.tryRecord({
|
|
891
|
+
event: "remote_visibility_filtered",
|
|
892
|
+
outcome: "observed",
|
|
893
|
+
actorId: currentId!,
|
|
894
|
+
remoteHostId: actor.info.remoteHostId,
|
|
895
|
+
generation: actor.info.generation,
|
|
896
|
+
visibleCount: sessions.length,
|
|
897
|
+
hiddenCount: allConnected.length - sessions.length,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
writeMessage(socket, { type: "sessions", requestId: clientMessage.requestId, sessions });
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
case "send": {
|
|
905
|
+
if (!currentId) {
|
|
906
|
+
throw new Error("Received send before register");
|
|
907
|
+
}
|
|
908
|
+
const message = clientMessage.message;
|
|
909
|
+
const messageId = isPlainDataRecord(message)
|
|
910
|
+
&& typeof message.id === "string"
|
|
911
|
+
&& message.id.length > 0
|
|
912
|
+
&& message.id.length <= MAX_MESSAGE_ID_LENGTH
|
|
913
|
+
? message.id
|
|
914
|
+
: "unknown";
|
|
915
|
+
|
|
916
|
+
if (
|
|
917
|
+
typeof clientMessage.to !== "string"
|
|
918
|
+
|| clientMessage.to.length === 0
|
|
919
|
+
|| clientMessage.to.length > MAX_TARGET_LENGTH
|
|
920
|
+
|| !isMessage(message)
|
|
921
|
+
) {
|
|
922
|
+
this.sendDeliveryFailure(socket, messageId, false, "INVALID_MESSAGE", "Invalid message format");
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const fromSession = this.sessions.get(currentId);
|
|
927
|
+
if (message.control !== undefined) {
|
|
928
|
+
const denial = bossControlDispatchDenial(clientMessage.to);
|
|
929
|
+
this.sendDeliveryFailure(socket, message.id, false, denial.code, denial.reason);
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
const action: SessionPolicyAction = message.replyTo
|
|
933
|
+
? "reply"
|
|
934
|
+
: message.expectsReply
|
|
935
|
+
? "ask"
|
|
936
|
+
: "send";
|
|
937
|
+
const authorizationContext: SessionAuthorizationContext = {};
|
|
938
|
+
this.pruneRecentDeliveries();
|
|
939
|
+
const deliveryKey = this.deliveryKey(currentId, message.id);
|
|
940
|
+
const fingerprint = JSON.stringify({
|
|
941
|
+
to: clientMessage.to,
|
|
942
|
+
replyTo: message.replyTo,
|
|
943
|
+
expectsReply: message.expectsReply,
|
|
944
|
+
control: message.control,
|
|
945
|
+
content: message.content,
|
|
946
|
+
});
|
|
947
|
+
const recent = this.recentDeliveries.get(deliveryKey);
|
|
948
|
+
if (recent) {
|
|
949
|
+
if (recent.fingerprint !== fingerprint) {
|
|
950
|
+
this.sendDeliveryFailure(socket, message.id, false, "DUPLICATE_MESSAGE_ID", "Message ID was already used with a different payload");
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
const actor = this.sessions.get(currentId);
|
|
954
|
+
const target = this.sessions.get(recent.to);
|
|
955
|
+
const authorizationStillValid = Boolean(
|
|
956
|
+
actor
|
|
957
|
+
&& target
|
|
958
|
+
&& this.authorizationEpoch(actor.info) === recent.fromGeneration
|
|
959
|
+
&& this.authorizationEpoch(target.info) === recent.toGeneration
|
|
960
|
+
&& this.isAuthorized(currentId, recent.action, recent.to, recent.controlKind === undefined
|
|
961
|
+
? {}
|
|
962
|
+
: { controlKind: recent.controlKind, correlated: false })
|
|
963
|
+
);
|
|
964
|
+
if (recent.retryable || !authorizationStillValid) {
|
|
965
|
+
this.recentDeliveries.delete(deliveryKey);
|
|
966
|
+
} else {
|
|
967
|
+
if (recent.response.type === "delivered") {
|
|
968
|
+
writeMessage(socket, {
|
|
969
|
+
type: "delivery_accepted",
|
|
970
|
+
messageId: message.id,
|
|
971
|
+
deliveryId: recent.response.deliveryId,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
writeMessage(socket, recent.response);
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
const existingDeliveryId = this.pendingDeliveryKeys.get(deliveryKey);
|
|
979
|
+
if (existingDeliveryId) {
|
|
980
|
+
const existing = this.pendingDeliveries.get(existingDeliveryId);
|
|
981
|
+
if (!existing || existing.fingerprint !== fingerprint) {
|
|
982
|
+
this.sendDeliveryFailure(socket, message.id, false, "DUPLICATE_MESSAGE_ID", "Message ID is already pending with a different payload");
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
const actor = this.sessions.get(existing.from);
|
|
986
|
+
const target = this.sessions.get(existing.to);
|
|
987
|
+
if (
|
|
988
|
+
actor
|
|
989
|
+
&& target
|
|
990
|
+
&& this.authorizationEpoch(actor.info) === existing.fromGeneration
|
|
991
|
+
&& this.authorizationEpoch(target.info) === existing.toGeneration
|
|
992
|
+
&& this.isAuthorized(existing.from, existing.action, existing.to, existing.controlKind === undefined
|
|
993
|
+
? {}
|
|
994
|
+
: { controlKind: existing.controlKind, correlated: false })
|
|
995
|
+
) {
|
|
996
|
+
writeMessage(socket, { type: "delivery_accepted", messageId: message.id, deliveryId: existing.id });
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
this.failPendingDelivery(existing.id, "SESSION_NOT_FOUND", "Delivery authorization changed while pending");
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (
|
|
1003
|
+
this.pendingDeliveries.size >= MAX_PENDING_DELIVERIES
|
|
1004
|
+
|| this.countPendingDeliveriesFrom(currentId) >= MAX_PENDING_DELIVERIES_PER_SESSION
|
|
1005
|
+
) {
|
|
1006
|
+
this.sendDeliveryFailure(socket, message.id, false, "TOO_MANY_PENDING_DELIVERIES", "Too many messages are waiting for receiver acknowledgement");
|
|
1007
|
+
break;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const candidates = this.findSessions(currentId, clientMessage.to);
|
|
1011
|
+
const targets = candidates.filter((target) => this.isAuthorized(currentId, action, target.info.id, authorizationContext));
|
|
1012
|
+
if (candidates.length > 0 && targets.length === 0) {
|
|
1013
|
+
const actor = this.sessions.get(currentId);
|
|
1014
|
+
this.audit.tryRecord({
|
|
1015
|
+
event: "remote_delivery_denied",
|
|
1016
|
+
outcome: "denied",
|
|
1017
|
+
actorId: currentId,
|
|
1018
|
+
targetId: candidates.length === 1 ? candidates[0].info.id : undefined,
|
|
1019
|
+
remoteHostId: actor?.info.remoteHostId ?? candidates.find((candidate) => candidate.info.remoteHostId)?.info.remoteHostId,
|
|
1020
|
+
generation: actor?.info.generation,
|
|
1021
|
+
reason: "POLICY_DENIED",
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
if (targets.length === 1) {
|
|
1025
|
+
if (!fromSession || fromSession.socket !== socket) {
|
|
1026
|
+
this.sendDeliveryFailure(socket, message.id, false, "SENDER_NOT_FOUND", "Sender session not found");
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
const target = targets[0];
|
|
1030
|
+
const replyEdge = message.replyTo
|
|
1031
|
+
? this.askEdges.get(this.askKey(target.info.id, message.replyTo))
|
|
1032
|
+
: undefined;
|
|
1033
|
+
if (message.replyTo && !replyEdge) {
|
|
1034
|
+
this.sendDeliveryFailure(socket, message.id, false, "INVALID_REPLY_TARGET", "Reply target does not match a pending ask");
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
if (replyEdge && (replyEdge.to !== currentId || replyEdge.from !== target.info.id)) {
|
|
1038
|
+
this.sendDeliveryFailure(socket, message.id, false, "INVALID_REPLY_TARGET", "Reply target does not match the pending ask");
|
|
1039
|
+
break;
|
|
1040
|
+
}
|
|
1041
|
+
if (message.expectsReply) {
|
|
1042
|
+
const existingAsk = Array.from(this.askEdges.values()).find((edge) =>
|
|
1043
|
+
edge.from === currentId && edge.to === target.info.id
|
|
1044
|
+
);
|
|
1045
|
+
if (existingAsk) {
|
|
1046
|
+
this.sendDeliveryFailure(socket, message.id, false, "ASK_ALREADY_PENDING", "Another ask to this session is still unresolved. Wait for its reply or use intercom_send for a non-blocking follow-up.");
|
|
1047
|
+
break;
|
|
1048
|
+
}
|
|
1049
|
+
const reverseEdge = Array.from(this.askEdges.values()).find((edge) =>
|
|
1050
|
+
edge.state === "blocking"
|
|
1051
|
+
&& !(message.replyTo === edge.messageId && target.info.id === edge.from)
|
|
1052
|
+
&& edge.from === target.info.id
|
|
1053
|
+
&& edge.to === currentId
|
|
1054
|
+
);
|
|
1055
|
+
if (reverseEdge) {
|
|
1056
|
+
this.sendDeliveryFailure(socket, message.id, false, "MUTUAL_ASK", "Mutual ask refused: target session is already waiting for a reply from this session.");
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
if (this.countAskEdgesFrom(currentId) >= MAX_PENDING_ASKS_PER_SESSION) {
|
|
1060
|
+
this.sendDeliveryFailure(socket, message.id, false, "TOO_MANY_PENDING_ASKS", "Too many asks are already waiting for replies");
|
|
1061
|
+
break;
|
|
1062
|
+
}
|
|
1063
|
+
this.addAskEdge(message.id, currentId, target.info.id);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const deliveryId = randomUUID();
|
|
1067
|
+
const timeout = setTimeout(() => {
|
|
1068
|
+
this.failPendingDelivery(deliveryId, "DELIVERY_TIMEOUT", "Recipient did not acknowledge the message in time");
|
|
1069
|
+
}, DELIVERY_ACK_TIMEOUT_MS);
|
|
1070
|
+
timeout.unref?.();
|
|
1071
|
+
const pending: PendingDelivery = {
|
|
1072
|
+
id: deliveryId,
|
|
1073
|
+
key: deliveryKey,
|
|
1074
|
+
fingerprint,
|
|
1075
|
+
message,
|
|
1076
|
+
from: currentId,
|
|
1077
|
+
to: target.info.id,
|
|
1078
|
+
senderSocket: socket,
|
|
1079
|
+
recipientSocket: target.socket,
|
|
1080
|
+
action,
|
|
1081
|
+
fromGeneration: this.authorizationEpoch(fromSession.info),
|
|
1082
|
+
toGeneration: this.authorizationEpoch(target.info),
|
|
1083
|
+
timeout,
|
|
1084
|
+
};
|
|
1085
|
+
this.pendingDeliveries.set(deliveryId, pending);
|
|
1086
|
+
this.pendingDeliveryKeys.set(deliveryKey, deliveryId);
|
|
1087
|
+
writeMessage(socket, { type: "delivery_accepted", messageId: message.id, deliveryId });
|
|
1088
|
+
writeMessage(target.socket, {
|
|
1089
|
+
type: "message",
|
|
1090
|
+
deliveryId,
|
|
1091
|
+
from: fromSession.info,
|
|
1092
|
+
message,
|
|
1093
|
+
});
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (targets.length > 1) {
|
|
1098
|
+
this.sendDeliveryFailure(socket, message.id, false, "AMBIGUOUS_TARGET", `Multiple sessions named \"${clientMessage.to}\" are connected. Use the session ID instead.`);
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
this.sendDeliveryFailure(socket, message.id, false, "SESSION_NOT_FOUND", "Session not found");
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
case "message_received": {
|
|
1107
|
+
if (!currentId) {
|
|
1108
|
+
throw new Error("Received message_received before register");
|
|
1109
|
+
}
|
|
1110
|
+
if (typeof clientMessage.deliveryId !== "string") {
|
|
1111
|
+
throw new Error("Invalid message_received message");
|
|
1112
|
+
}
|
|
1113
|
+
this.acknowledgePendingDelivery(clientMessage.deliveryId, currentId, socket);
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
case "message_rejected": {
|
|
1118
|
+
if (!currentId) {
|
|
1119
|
+
throw new Error("Received message_rejected before register");
|
|
1120
|
+
}
|
|
1121
|
+
if (
|
|
1122
|
+
typeof clientMessage.deliveryId !== "string"
|
|
1123
|
+
|| clientMessage.code !== "CONFLICTING_MESSAGE_ID"
|
|
1124
|
+
|| typeof clientMessage.reason !== "string"
|
|
1125
|
+
|| clientMessage.reason.length > 1024
|
|
1126
|
+
) {
|
|
1127
|
+
throw new Error("Invalid message_rejected message");
|
|
1128
|
+
}
|
|
1129
|
+
const pending = this.pendingDeliveries.get(clientMessage.deliveryId);
|
|
1130
|
+
if (pending?.to === currentId && pending.recipientSocket === socket) {
|
|
1131
|
+
this.failPendingDelivery(clientMessage.deliveryId, clientMessage.code, clientMessage.reason);
|
|
1132
|
+
}
|
|
1133
|
+
break;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
case "defer_ask": {
|
|
1137
|
+
if (!currentId) {
|
|
1138
|
+
throw new Error("Received defer_ask before register");
|
|
1139
|
+
}
|
|
1140
|
+
if (
|
|
1141
|
+
typeof clientMessage.messageId !== "string"
|
|
1142
|
+
|| clientMessage.messageId.length > MAX_MESSAGE_ID_LENGTH
|
|
1143
|
+
|| typeof clientMessage.requestId !== "string"
|
|
1144
|
+
|| clientMessage.requestId.length > MAX_MESSAGE_ID_LENGTH
|
|
1145
|
+
) {
|
|
1146
|
+
throw new Error("Invalid defer_ask message");
|
|
1147
|
+
}
|
|
1148
|
+
const session = this.sessions.get(currentId);
|
|
1149
|
+
const edge = this.askEdges.get(this.askKey(currentId, clientMessage.messageId));
|
|
1150
|
+
const applied = Boolean(session?.socket === socket && edge?.from === currentId);
|
|
1151
|
+
if (applied && edge?.state === "blocking") {
|
|
1152
|
+
edge.state = "deferred";
|
|
1153
|
+
this.persistAskEdges();
|
|
1154
|
+
this.notifyAskDeferred(edge);
|
|
1155
|
+
}
|
|
1156
|
+
writeMessage(socket, { type: "ask_control_result", requestId: clientMessage.requestId, action: "defer", messageId: clientMessage.messageId, applied });
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
case "cancel_ask": {
|
|
1161
|
+
if (!currentId) {
|
|
1162
|
+
throw new Error("Received cancel_ask before register");
|
|
1163
|
+
}
|
|
1164
|
+
if (
|
|
1165
|
+
typeof clientMessage.messageId !== "string"
|
|
1166
|
+
|| clientMessage.messageId.length > MAX_MESSAGE_ID_LENGTH
|
|
1167
|
+
|| typeof clientMessage.requestId !== "string"
|
|
1168
|
+
|| clientMessage.requestId.length > MAX_MESSAGE_ID_LENGTH
|
|
1169
|
+
) {
|
|
1170
|
+
throw new Error("Invalid cancel_ask message");
|
|
1171
|
+
}
|
|
1172
|
+
const session = this.sessions.get(currentId);
|
|
1173
|
+
const edgeKey = this.askKey(currentId, clientMessage.messageId);
|
|
1174
|
+
const edge = this.askEdges.get(edgeKey);
|
|
1175
|
+
const applied = Boolean(session?.socket === socket && edge?.from === currentId);
|
|
1176
|
+
if (applied) {
|
|
1177
|
+
this.removeAskEdge(edgeKey, "cancelled", true);
|
|
1178
|
+
}
|
|
1179
|
+
writeMessage(socket, { type: "ask_control_result", requestId: clientMessage.requestId, action: "cancel", messageId: clientMessage.messageId, applied });
|
|
1180
|
+
break;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
case "presence": {
|
|
1184
|
+
if (!currentId) {
|
|
1185
|
+
throw new Error("Received presence before register");
|
|
1186
|
+
}
|
|
1187
|
+
const session = this.sessions.get(currentId);
|
|
1188
|
+
if (session?.socket === socket) {
|
|
1189
|
+
let changed = false;
|
|
1190
|
+
if (clientMessage.name !== undefined) {
|
|
1191
|
+
if (typeof clientMessage.name !== "string" || clientMessage.name.length > MAX_SESSION_NAME_LENGTH) {
|
|
1192
|
+
throw new Error("Invalid presence name");
|
|
1193
|
+
}
|
|
1194
|
+
if (session.info.origin !== "remote" && session.info.name !== clientMessage.name) {
|
|
1195
|
+
session.info.name = clientMessage.name;
|
|
1196
|
+
changed = true;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (clientMessage.status !== undefined) {
|
|
1200
|
+
if (typeof clientMessage.status !== "string" || clientMessage.status.length > MAX_SESSION_STATUS_LENGTH) {
|
|
1201
|
+
throw new Error("Invalid presence status");
|
|
1202
|
+
}
|
|
1203
|
+
if (session.info.status !== clientMessage.status) {
|
|
1204
|
+
session.info.status = clientMessage.status;
|
|
1205
|
+
changed = true;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
if (clientMessage.model !== undefined) {
|
|
1209
|
+
if (typeof clientMessage.model !== "string" || clientMessage.model.length > MAX_SESSION_MODEL_LENGTH) {
|
|
1210
|
+
throw new Error("Invalid presence model");
|
|
1211
|
+
}
|
|
1212
|
+
if (session.info.model !== clientMessage.model) {
|
|
1213
|
+
session.info.model = clientMessage.model;
|
|
1214
|
+
changed = true;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
const now = Date.now();
|
|
1218
|
+
session.info.lastActivity = now;
|
|
1219
|
+
if (changed || now - session.lastPresenceBroadcastAt >= PRESENCE_HEARTBEAT_MS) {
|
|
1220
|
+
session.lastPresenceBroadcastAt = now;
|
|
1221
|
+
this.broadcastLifecycle({ type: "presence_update", session: session.info }, session, currentId);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
break;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
default:
|
|
1228
|
+
throw new Error(`Unknown client message type: ${clientMessage.type}`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private remoteAccessContract() {
|
|
1233
|
+
return {
|
|
1234
|
+
feature: "remote-access-v1" as const,
|
|
1235
|
+
policySemanticsVersion: POLICY_SEMANTICS_VERSION,
|
|
1236
|
+
policySemanticsHash: POLICY_SEMANTICS_HASH,
|
|
1237
|
+
};
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
private handleAccessControl(socket: net.Socket, message: Record<string, unknown>): void {
|
|
1241
|
+
if (
|
|
1242
|
+
typeof message.requestId !== "string"
|
|
1243
|
+
|| message.requestId.length > MAX_MESSAGE_ID_LENGTH
|
|
1244
|
+
|| typeof message.adminToken !== "string"
|
|
1245
|
+
|| !this.accessRegistry.authenticateAdmin(message.adminToken)
|
|
1246
|
+
) {
|
|
1247
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote access control credential or request was rejected");
|
|
1248
|
+
socket.end();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (message.action === "inspect_tree") {
|
|
1252
|
+
if (typeof message.principalId !== "string" || !isSessionId(message.principalId)) {
|
|
1253
|
+
this.sendError(socket, "INVALID_REQUEST", "Invalid remote principal ID");
|
|
1254
|
+
socket.end();
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const principals = this.accessRegistry.inspectSubtree(message.principalId).map((principal) => this.principalSummary(principal));
|
|
1258
|
+
this.audit.record({
|
|
1259
|
+
event: "tree_inspected",
|
|
1260
|
+
outcome: "allowed",
|
|
1261
|
+
actorId: "local-admin",
|
|
1262
|
+
targetId: message.principalId,
|
|
1263
|
+
visibleCount: principals.length,
|
|
1264
|
+
});
|
|
1265
|
+
writeMessage(socket, { type: "access_control_result", requestId: message.requestId, action: "inspect_tree", principals });
|
|
1266
|
+
socket.end();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (message.action === "adopt_subtree") {
|
|
1270
|
+
if (
|
|
1271
|
+
typeof message.principalId !== "string"
|
|
1272
|
+
|| !isSessionId(message.principalId)
|
|
1273
|
+
|| typeof message.newParentSessionId !== "string"
|
|
1274
|
+
|| !isSessionId(message.newParentSessionId)
|
|
1275
|
+
) {
|
|
1276
|
+
this.sendError(socket, "INVALID_REQUEST", "Invalid adoption request");
|
|
1277
|
+
socket.end();
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
const localParent = this.sessions.get(message.newParentSessionId);
|
|
1281
|
+
const remoteParent = this.accessRegistry.snapshot().principals[message.newParentSessionId];
|
|
1282
|
+
if ((!localParent || localParent.info.origin === "remote") && (!remoteParent || remoteParent.state !== "active")) {
|
|
1283
|
+
this.sendError(socket, "ACCESS_DENIED", "Adoption parent must be an active local or remote principal");
|
|
1284
|
+
socket.end();
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
const newRootSessionId = localParent?.info.origin === "local"
|
|
1288
|
+
? localParent.info.id
|
|
1289
|
+
: remoteParent!.rootSessionId;
|
|
1290
|
+
const priorSessions = Array.from(this.sessions.values(), (session) => session.info);
|
|
1291
|
+
let changed: RemotePrincipalRecord[];
|
|
1292
|
+
try {
|
|
1293
|
+
changed = this.accessRegistry.adoptSubtree(message.principalId, message.newParentSessionId, newRootSessionId);
|
|
1294
|
+
} catch {
|
|
1295
|
+
this.sendError(socket, "ACCESS_DENIED", "Adoption would violate the ownership tree");
|
|
1296
|
+
socket.end();
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
this.disconnectTransitionedPrincipals(changed, priorSessions, "principal_adopted");
|
|
1300
|
+
writeMessage(socket, {
|
|
1301
|
+
type: "access_control_result",
|
|
1302
|
+
requestId: message.requestId,
|
|
1303
|
+
action: "adopt_subtree",
|
|
1304
|
+
principals: changed.map((principal) => this.principalSummary(principal)),
|
|
1305
|
+
});
|
|
1306
|
+
socket.end();
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (message.action === "revoke_subtree") {
|
|
1310
|
+
if (typeof message.principalId !== "string" || !isSessionId(message.principalId)) {
|
|
1311
|
+
this.sendError(socket, "INVALID_REQUEST", "Invalid remote principal ID");
|
|
1312
|
+
socket.end();
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const priorSessions = Array.from(this.sessions.values(), (session) => session.info);
|
|
1316
|
+
const changed = this.accessRegistry.revoke(message.principalId);
|
|
1317
|
+
this.disconnectTransitionedPrincipals(changed, priorSessions);
|
|
1318
|
+
writeMessage(socket, {
|
|
1319
|
+
type: "access_control_result",
|
|
1320
|
+
requestId: message.requestId,
|
|
1321
|
+
action: "revoke_subtree",
|
|
1322
|
+
changedPrincipalIds: changed.map((principal) => principal.id),
|
|
1323
|
+
});
|
|
1324
|
+
socket.end();
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (
|
|
1328
|
+
message.action !== "issue_enrollment"
|
|
1329
|
+
|| typeof message.enrollment !== "object"
|
|
1330
|
+
|| message.enrollment === null
|
|
1331
|
+
|| Array.isArray(message.enrollment)
|
|
1332
|
+
) {
|
|
1333
|
+
this.sendError(socket, "INVALID_REQUEST", "Unknown remote access control action");
|
|
1334
|
+
socket.end();
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const enrollment = message.enrollment as Record<string, unknown>;
|
|
1338
|
+
if (
|
|
1339
|
+
typeof enrollment.name !== "string"
|
|
1340
|
+
|| typeof enrollment.parentSessionId !== "string"
|
|
1341
|
+
|| typeof enrollment.rootSessionId !== "string"
|
|
1342
|
+
|| typeof enrollment.remoteHostId !== "string"
|
|
1343
|
+
|| (enrollment.ttlMs !== undefined && (typeof enrollment.ttlMs !== "number" || !Number.isSafeInteger(enrollment.ttlMs)))
|
|
1344
|
+
|| (enrollment.expiresAt !== undefined && (typeof enrollment.expiresAt !== "number" || !Number.isSafeInteger(enrollment.expiresAt)))
|
|
1345
|
+
|| (enrollment.canDelegate !== undefined && typeof enrollment.canDelegate !== "boolean")
|
|
1346
|
+
|| (enrollment.maxDepth !== undefined && (typeof enrollment.maxDepth !== "number" || !Number.isSafeInteger(enrollment.maxDepth)))
|
|
1347
|
+
|| (enrollment.maxChildren !== undefined && (typeof enrollment.maxChildren !== "number" || !Number.isSafeInteger(enrollment.maxChildren)))
|
|
1348
|
+
) {
|
|
1349
|
+
this.sendError(socket, "INVALID_REQUEST", "Invalid remote enrollment request");
|
|
1350
|
+
socket.end();
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const parent = this.sessions.get(enrollment.parentSessionId);
|
|
1354
|
+
if (!parent || parent.info.origin === "remote" || enrollment.rootSessionId !== parent.info.id) {
|
|
1355
|
+
this.sendError(socket, "ACCESS_DENIED", "Enrollment parent must be an active local root session");
|
|
1356
|
+
socket.end();
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
const issued = this.accessRegistry.issueEnrollment({
|
|
1360
|
+
name: enrollment.name,
|
|
1361
|
+
parentSessionId: parent.info.id,
|
|
1362
|
+
rootSessionId: parent.info.id,
|
|
1363
|
+
remoteHostId: enrollment.remoteHostId,
|
|
1364
|
+
...(enrollment.expiresAt !== undefined ? { expiresAt: enrollment.expiresAt as number } : {}),
|
|
1365
|
+
...(enrollment.canDelegate !== undefined ? { canDelegate: enrollment.canDelegate as boolean } : {}),
|
|
1366
|
+
...(enrollment.maxDepth !== undefined ? { maxDepth: enrollment.maxDepth as number } : {}),
|
|
1367
|
+
...(enrollment.maxChildren !== undefined ? { maxChildren: enrollment.maxChildren as number } : {}),
|
|
1368
|
+
}, enrollment.ttlMs as number | undefined);
|
|
1369
|
+
this.audit.record({
|
|
1370
|
+
event: "enrollment_issued",
|
|
1371
|
+
outcome: "allowed",
|
|
1372
|
+
actorId: parent.info.id,
|
|
1373
|
+
targetId: enrollment.name,
|
|
1374
|
+
remoteHostId: enrollment.remoteHostId,
|
|
1375
|
+
reason: `expires:${issued.expiresAt}`,
|
|
1376
|
+
});
|
|
1377
|
+
writeMessage(socket, {
|
|
1378
|
+
type: "access_control_result",
|
|
1379
|
+
requestId: message.requestId,
|
|
1380
|
+
action: "issue_enrollment",
|
|
1381
|
+
enrollmentToken: issued.enrollmentToken,
|
|
1382
|
+
expiresAt: issued.expiresAt,
|
|
1383
|
+
});
|
|
1384
|
+
socket.end();
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
private handleRemoteAccessControl(socket: net.Socket, message: Record<string, unknown>): void {
|
|
1388
|
+
if (
|
|
1389
|
+
typeof message.requestId !== "string"
|
|
1390
|
+
|| message.requestId.length > MAX_MESSAGE_ID_LENGTH
|
|
1391
|
+
|| typeof message.access !== "object"
|
|
1392
|
+
|| message.access === null
|
|
1393
|
+
|| Array.isArray(message.access)
|
|
1394
|
+
) {
|
|
1395
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote control request was rejected");
|
|
1396
|
+
socket.end();
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
const access = message.access as Record<string, unknown>;
|
|
1400
|
+
if (
|
|
1401
|
+
typeof access.sessionCredential !== "string"
|
|
1402
|
+
|| typeof access.sessionId !== "string"
|
|
1403
|
+
|| typeof access.generation !== "number"
|
|
1404
|
+
|| !Number.isSafeInteger(access.generation)
|
|
1405
|
+
) {
|
|
1406
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote control credential was rejected");
|
|
1407
|
+
socket.end();
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
let parent: RemotePrincipalRecord;
|
|
1411
|
+
try {
|
|
1412
|
+
parent = this.accessRegistry.authenticateSession(access.sessionId, access.generation, access.sessionCredential);
|
|
1413
|
+
} catch {
|
|
1414
|
+
this.audit.tryRecord({ event: "remote_registration_denied", outcome: "denied", reason: "INVALID_CONTROL_CREDENTIAL" });
|
|
1415
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote control credential was rejected");
|
|
1416
|
+
socket.end();
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
const policyState = this.registryPolicyState();
|
|
1420
|
+
if (message.action === "inspect_tree") {
|
|
1421
|
+
const targetId = typeof message.principalId === "string" ? message.principalId : parent.id;
|
|
1422
|
+
const inspection = authorize(policyState, parent.id, "inspect_tree", targetId, { actorGeneration: parent.generation });
|
|
1423
|
+
if (!inspection.allowed) {
|
|
1424
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote tree inspection policy denied the request");
|
|
1425
|
+
socket.end();
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
const principals = this.accessRegistry.inspectSubtree(targetId)
|
|
1429
|
+
.filter((candidate) => authorize(policyState, parent.id, "inspect_tree", candidate.id, { actorGeneration: parent.generation }).allowed)
|
|
1430
|
+
.map((candidate) => this.principalSummary(candidate));
|
|
1431
|
+
this.audit.record({
|
|
1432
|
+
event: "tree_inspected",
|
|
1433
|
+
outcome: "allowed",
|
|
1434
|
+
actorId: parent.id,
|
|
1435
|
+
targetId,
|
|
1436
|
+
remoteHostId: parent.remoteHostId,
|
|
1437
|
+
generation: parent.generation,
|
|
1438
|
+
visibleCount: principals.length,
|
|
1439
|
+
});
|
|
1440
|
+
writeMessage(socket, { type: "access_control_result", requestId: message.requestId, action: "inspect_tree", principals });
|
|
1441
|
+
socket.end();
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (
|
|
1445
|
+
message.action !== "issue_child_enrollment"
|
|
1446
|
+
|| typeof message.enrollment !== "object"
|
|
1447
|
+
|| message.enrollment === null
|
|
1448
|
+
|| Array.isArray(message.enrollment)
|
|
1449
|
+
) {
|
|
1450
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote control action was rejected");
|
|
1451
|
+
socket.end();
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const enrollment = message.enrollment as Record<string, unknown>;
|
|
1455
|
+
if (
|
|
1456
|
+
typeof enrollment.name !== "string"
|
|
1457
|
+
|| (enrollment.ttlMs !== undefined && (typeof enrollment.ttlMs !== "number" || !Number.isSafeInteger(enrollment.ttlMs)))
|
|
1458
|
+
|| (enrollment.expiresAt !== undefined && (typeof enrollment.expiresAt !== "number" || !Number.isSafeInteger(enrollment.expiresAt)))
|
|
1459
|
+
|| (enrollment.canDelegate !== undefined && typeof enrollment.canDelegate !== "boolean")
|
|
1460
|
+
|| (enrollment.maxDepth !== undefined && (typeof enrollment.maxDepth !== "number" || !Number.isSafeInteger(enrollment.maxDepth)))
|
|
1461
|
+
|| (enrollment.maxChildren !== undefined && (typeof enrollment.maxChildren !== "number" || !Number.isSafeInteger(enrollment.maxChildren)))
|
|
1462
|
+
) {
|
|
1463
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote delegation request was rejected");
|
|
1464
|
+
socket.end();
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
const delegation = authorize(policyState, parent.id, "delegate_child", parent.id, {
|
|
1468
|
+
actorGeneration: parent.generation,
|
|
1469
|
+
targetGeneration: parent.generation,
|
|
1470
|
+
});
|
|
1471
|
+
if (!delegation.allowed) {
|
|
1472
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote delegation policy denied the request");
|
|
1473
|
+
socket.end();
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
let issued;
|
|
1477
|
+
try {
|
|
1478
|
+
issued = this.accessRegistry.issueChildEnrollment(parent.id, parent.generation, {
|
|
1479
|
+
name: enrollment.name,
|
|
1480
|
+
...(enrollment.expiresAt !== undefined ? { expiresAt: enrollment.expiresAt as number } : {}),
|
|
1481
|
+
...(enrollment.canDelegate !== undefined ? { canDelegate: enrollment.canDelegate as boolean } : {}),
|
|
1482
|
+
...(enrollment.maxDepth !== undefined ? { maxDepth: enrollment.maxDepth as number } : {}),
|
|
1483
|
+
...(enrollment.maxChildren !== undefined ? { maxChildren: enrollment.maxChildren as number } : {}),
|
|
1484
|
+
}, enrollment.ttlMs as number | undefined);
|
|
1485
|
+
} catch {
|
|
1486
|
+
this.sendError(socket, "ACCESS_DENIED", "Remote delegation limits denied the request");
|
|
1487
|
+
socket.end();
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
this.audit.record({
|
|
1491
|
+
event: "enrollment_issued",
|
|
1492
|
+
outcome: "allowed",
|
|
1493
|
+
actorId: parent.id,
|
|
1494
|
+
targetId: enrollment.name,
|
|
1495
|
+
remoteHostId: parent.remoteHostId,
|
|
1496
|
+
generation: parent.generation,
|
|
1497
|
+
reason: `delegated-expires:${issued.expiresAt}`,
|
|
1498
|
+
});
|
|
1499
|
+
writeMessage(socket, {
|
|
1500
|
+
type: "access_control_result",
|
|
1501
|
+
requestId: message.requestId,
|
|
1502
|
+
action: "issue_child_enrollment",
|
|
1503
|
+
enrollmentToken: issued.enrollmentToken,
|
|
1504
|
+
expiresAt: issued.expiresAt,
|
|
1505
|
+
parentSessionId: parent.id,
|
|
1506
|
+
});
|
|
1507
|
+
socket.end();
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
private registryPolicyState(): PolicyState {
|
|
1511
|
+
const records = this.accessRegistry.snapshot().principals;
|
|
1512
|
+
const principals: PolicyState["principals"] = {};
|
|
1513
|
+
for (const record of Object.values(records)) {
|
|
1514
|
+
principals[record.id] = {
|
|
1515
|
+
id: record.id,
|
|
1516
|
+
kind: "remote",
|
|
1517
|
+
state: record.state,
|
|
1518
|
+
generation: record.generation,
|
|
1519
|
+
policy: "remote-tree",
|
|
1520
|
+
parentSessionId: record.parentSessionId,
|
|
1521
|
+
rootSessionId: record.rootSessionId,
|
|
1522
|
+
};
|
|
1523
|
+
if (!principals[record.rootSessionId]) {
|
|
1524
|
+
principals[record.rootSessionId] = {
|
|
1525
|
+
id: record.rootSessionId,
|
|
1526
|
+
kind: "local",
|
|
1527
|
+
state: "active",
|
|
1528
|
+
generation: 1,
|
|
1529
|
+
policy: "local-public",
|
|
1530
|
+
rootSessionId: record.rootSessionId,
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
return { principals };
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private principalSummary(principal: RemotePrincipalMetadata): RemotePrincipalSummary {
|
|
1538
|
+
return { ...principal, connected: this.sessions.has(principal.id) };
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
private reconcileExpiredPrincipals(): void {
|
|
1542
|
+
const priorSessions = Array.from(this.sessions.values(), (session) => session.info);
|
|
1543
|
+
const changed = this.accessRegistry.expirePrincipals();
|
|
1544
|
+
if (changed.length > 0) this.disconnectTransitionedPrincipals(changed, priorSessions, "principal_expired");
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
private disconnectTransitionedPrincipals(
|
|
1548
|
+
changed: RemotePrincipalRecord[],
|
|
1549
|
+
priorSessions: SessionInfo[],
|
|
1550
|
+
auditEvent: "principal_revoked" | "principal_expired" | "principal_adopted" = "principal_revoked",
|
|
1551
|
+
): void {
|
|
1552
|
+
const changedIds = new Set(changed.map((principal) => principal.id));
|
|
1553
|
+
for (const principal of changed) {
|
|
1554
|
+
const live = this.sessions.get(principal.id);
|
|
1555
|
+
if (!live) {
|
|
1556
|
+
this.audit.record({
|
|
1557
|
+
event: auditEvent,
|
|
1558
|
+
outcome: "allowed",
|
|
1559
|
+
actorId: principal.id,
|
|
1560
|
+
remoteHostId: principal.remoteHostId,
|
|
1561
|
+
generation: principal.generation,
|
|
1562
|
+
reason: "OFFLINE",
|
|
1563
|
+
});
|
|
1564
|
+
continue;
|
|
1565
|
+
}
|
|
1566
|
+
const subject = priorSessions.find((session) => session.id === principal.id) ?? live.info;
|
|
1567
|
+
for (const [recipientId, recipient] of this.sessions) {
|
|
1568
|
+
if (
|
|
1569
|
+
recipientId !== principal.id
|
|
1570
|
+
&& !changedIds.has(recipientId)
|
|
1571
|
+
&& sameIntercomScope(recipient.scopeId, live.scopeId)
|
|
1572
|
+
&& authorizeSessionAction(priorSessions, recipientId, "discover", principal.id).allowed
|
|
1573
|
+
) {
|
|
1574
|
+
writeMessage(recipient.socket, { type: "session_left", sessionId: principal.id });
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
this.clearPendingDeliveriesForSession(principal.id, live.socket);
|
|
1578
|
+
this.clearAskEdgesForSession(principal.id, "authorization_revoked");
|
|
1579
|
+
this.sessions.delete(principal.id);
|
|
1580
|
+
for (const [key, recent] of this.recentDeliveries) {
|
|
1581
|
+
if (recent.from === principal.id || recent.to === principal.id) this.recentDeliveries.delete(key);
|
|
1582
|
+
}
|
|
1583
|
+
this.audit.record({
|
|
1584
|
+
event: auditEvent,
|
|
1585
|
+
outcome: "allowed",
|
|
1586
|
+
actorId: principal.id,
|
|
1587
|
+
targetId: subject.parentSessionId,
|
|
1588
|
+
remoteHostId: principal.remoteHostId,
|
|
1589
|
+
generation: principal.generation,
|
|
1590
|
+
reason: "DISCONNECTED",
|
|
1591
|
+
});
|
|
1592
|
+
live.socket.destroy();
|
|
1593
|
+
}
|
|
1594
|
+
if (changed.length > 0) this.scheduleShutdownCheck();
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
private isCurrentPrincipal(sessionId: string): boolean {
|
|
1598
|
+
const session = this.sessions.get(sessionId);
|
|
1599
|
+
if (!session) return false;
|
|
1600
|
+
if (session.info.origin !== "remote") return true;
|
|
1601
|
+
try {
|
|
1602
|
+
this.accessRegistry.validatePrincipal(sessionId, session.info.generation ?? 0);
|
|
1603
|
+
return true;
|
|
1604
|
+
} catch {
|
|
1605
|
+
return false;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
private authorizationEpoch(session: SessionInfo): number {
|
|
1610
|
+
return Number(session.boss?.binding.bindingEpoch ?? session.generation ?? 1);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
private isAuthorized(
|
|
1614
|
+
actorId: string,
|
|
1615
|
+
action: SessionPolicyAction,
|
|
1616
|
+
targetId: string,
|
|
1617
|
+
context: SessionAuthorizationContext = {},
|
|
1618
|
+
): boolean {
|
|
1619
|
+
if (!this.isCurrentPrincipal(actorId) || !this.isCurrentPrincipal(targetId)) return false;
|
|
1620
|
+
return authorizeSessionAction(
|
|
1621
|
+
Array.from(this.sessions.values(), (session) => session.info),
|
|
1622
|
+
actorId,
|
|
1623
|
+
action,
|
|
1624
|
+
targetId,
|
|
1625
|
+
context,
|
|
1626
|
+
).allowed;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
private broadcastLifecycle(message: BrokerMessage, subject: ConnectedSession, exclude?: string): void {
|
|
1630
|
+
for (const [id, session] of this.sessions) {
|
|
1631
|
+
if (
|
|
1632
|
+
id !== exclude
|
|
1633
|
+
&& sameIntercomScope(session.scopeId, subject.scopeId)
|
|
1634
|
+
&& this.isAuthorized(id, "discover", subject.info.id)
|
|
1635
|
+
) {
|
|
1636
|
+
writeMessage(session.socket, message);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
private askKey(fromSessionId: string, messageId: string): string {
|
|
1642
|
+
return `${fromSessionId}\u0000${messageId}`;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
private deliveryKey(fromSessionId: string, messageId: string): string {
|
|
1646
|
+
return `${fromSessionId}\u0000${messageId}`;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
private addAskEdge(messageId: string, from: string, to: string): void {
|
|
1650
|
+
const key = this.askKey(from, messageId);
|
|
1651
|
+
const previous = this.askEdges.get(key);
|
|
1652
|
+
if (previous) {
|
|
1653
|
+
clearTimeout(previous.timeout);
|
|
1654
|
+
}
|
|
1655
|
+
const createdAt = Date.now();
|
|
1656
|
+
const expiresAt = createdAt + this.askTimeoutMs;
|
|
1657
|
+
this.askEdges.set(key, {
|
|
1658
|
+
messageId,
|
|
1659
|
+
from,
|
|
1660
|
+
to,
|
|
1661
|
+
createdAt,
|
|
1662
|
+
expiresAt,
|
|
1663
|
+
state: "blocking",
|
|
1664
|
+
timeout: this.scheduleAskExpiry(key, expiresAt),
|
|
1665
|
+
});
|
|
1666
|
+
this.persistAskEdges();
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
private removeAskEdge(key: string, reason?: AskCancellationReason, notifyRecipient = false): void {
|
|
1670
|
+
const edge = this.askEdges.get(key);
|
|
1671
|
+
if (!edge) {
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
clearTimeout(edge.timeout);
|
|
1675
|
+
this.askEdges.delete(key);
|
|
1676
|
+
this.persistAskEdges();
|
|
1677
|
+
if (reason && notifyRecipient) {
|
|
1678
|
+
this.notifyAskCancelled(edge.to, edge.messageId, edge.from, reason);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
private notifyAskDeferred(edge: AskEdge): void {
|
|
1683
|
+
const recipient = this.sessions.get(edge.to);
|
|
1684
|
+
if (recipient) {
|
|
1685
|
+
writeMessage(recipient.socket, {
|
|
1686
|
+
type: "ask_deferred",
|
|
1687
|
+
messageId: edge.messageId,
|
|
1688
|
+
fromSessionId: edge.from,
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
private notifyAskCancelled(
|
|
1694
|
+
sessionId: string,
|
|
1695
|
+
messageId: string,
|
|
1696
|
+
fromSessionId: string,
|
|
1697
|
+
reason: AskCancellationReason,
|
|
1698
|
+
): void {
|
|
1699
|
+
const session = this.sessions.get(sessionId);
|
|
1700
|
+
if (session) {
|
|
1701
|
+
writeMessage(session.socket, { type: "ask_cancelled", messageId, fromSessionId, reason });
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
private clearAskEdgesForSession(sessionId: string, reason: AskCancellationReason): void {
|
|
1706
|
+
let changed = false;
|
|
1707
|
+
for (const [key, edge] of this.askEdges) {
|
|
1708
|
+
if (edge.from === sessionId || edge.to === sessionId) {
|
|
1709
|
+
clearTimeout(edge.timeout);
|
|
1710
|
+
this.askEdges.delete(key);
|
|
1711
|
+
changed = true;
|
|
1712
|
+
if (edge.from === sessionId) {
|
|
1713
|
+
this.notifyAskCancelled(edge.to, edge.messageId, edge.from, reason);
|
|
1714
|
+
} else {
|
|
1715
|
+
this.notifyAskCancelled(edge.from, edge.messageId, edge.to, reason);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
if (changed) {
|
|
1720
|
+
this.persistAskEdges();
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
private deferAskEdgesForSession(sessionId: string): void {
|
|
1725
|
+
let changed = false;
|
|
1726
|
+
for (const edge of this.askEdges.values()) {
|
|
1727
|
+
if ((edge.from === sessionId || edge.to === sessionId) && edge.state === "blocking") {
|
|
1728
|
+
edge.state = "deferred";
|
|
1729
|
+
changed = true;
|
|
1730
|
+
if (edge.from === sessionId) {
|
|
1731
|
+
this.notifyAskDeferred(edge);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (changed) {
|
|
1736
|
+
this.persistAskEdges();
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
private scheduleAskExpiry(key: string, expiresAt: number): NodeJS.Timeout {
|
|
1741
|
+
const delay = Math.max(1, Math.min(expiresAt - Date.now(), 2_147_483_647));
|
|
1742
|
+
const timeout = setTimeout(() => {
|
|
1743
|
+
if (expiresAt > Date.now()) {
|
|
1744
|
+
const edge = this.askEdges.get(key);
|
|
1745
|
+
if (edge) {
|
|
1746
|
+
clearTimeout(edge.timeout);
|
|
1747
|
+
edge.timeout = this.scheduleAskExpiry(key, expiresAt);
|
|
1748
|
+
}
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
this.removeAskEdge(key, "expired", true);
|
|
1752
|
+
}, delay);
|
|
1753
|
+
timeout.unref?.();
|
|
1754
|
+
return timeout;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
private loadAskEdges(): void {
|
|
1758
|
+
if (!existsSync(ASK_STATE_PATH)) {
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
try {
|
|
1763
|
+
const parsed: unknown = JSON.parse(readFileSync(ASK_STATE_PATH, "utf-8"));
|
|
1764
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1765
|
+
throw new Error("expected an object");
|
|
1766
|
+
}
|
|
1767
|
+
const state = parsed as Record<string, unknown>;
|
|
1768
|
+
if (state.version !== 1) {
|
|
1769
|
+
throw new Error("unsupported state version");
|
|
1770
|
+
}
|
|
1771
|
+
const edges = state.edges;
|
|
1772
|
+
if (!Array.isArray(edges)) {
|
|
1773
|
+
throw new Error("expected an edges array");
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const now = Date.now();
|
|
1777
|
+
for (const candidate of edges) {
|
|
1778
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
1779
|
+
continue;
|
|
1780
|
+
}
|
|
1781
|
+
const edge = candidate as Record<string, unknown>;
|
|
1782
|
+
if (
|
|
1783
|
+
typeof edge.messageId !== "string"
|
|
1784
|
+
|| edge.messageId.length === 0
|
|
1785
|
+
|| edge.messageId.length > MAX_MESSAGE_ID_LENGTH
|
|
1786
|
+
|| !isSessionId(edge.from)
|
|
1787
|
+
|| !isSessionId(edge.to)
|
|
1788
|
+
|| typeof edge.createdAt !== "number"
|
|
1789
|
+
|| !Number.isFinite(edge.createdAt)
|
|
1790
|
+
|| typeof edge.expiresAt !== "number"
|
|
1791
|
+
|| !Number.isFinite(edge.expiresAt)
|
|
1792
|
+
|| edge.expiresAt <= now
|
|
1793
|
+
|| (edge.state !== "blocking" && edge.state !== "deferred")
|
|
1794
|
+
) {
|
|
1795
|
+
continue;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
const key = this.askKey(edge.from, edge.messageId);
|
|
1799
|
+
this.askEdges.set(key, {
|
|
1800
|
+
messageId: edge.messageId,
|
|
1801
|
+
from: edge.from,
|
|
1802
|
+
to: edge.to,
|
|
1803
|
+
createdAt: edge.createdAt,
|
|
1804
|
+
expiresAt: edge.expiresAt,
|
|
1805
|
+
state: "deferred",
|
|
1806
|
+
timeout: this.scheduleAskExpiry(key, edge.expiresAt),
|
|
1807
|
+
});
|
|
1808
|
+
}
|
|
1809
|
+
this.persistAskEdges();
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
console.error(`Failed to load persisted ask state at ${ASK_STATE_PATH}:`, error);
|
|
1812
|
+
for (const edge of this.askEdges.values()) {
|
|
1813
|
+
clearTimeout(edge.timeout);
|
|
1814
|
+
}
|
|
1815
|
+
this.askEdges.clear();
|
|
1816
|
+
try {
|
|
1817
|
+
const corruptPath = `${ASK_STATE_PATH}.corrupt-${Date.now()}`;
|
|
1818
|
+
renameSync(ASK_STATE_PATH, corruptPath);
|
|
1819
|
+
restrictIntercomRuntimeFile(corruptPath);
|
|
1820
|
+
} catch {
|
|
1821
|
+
// Keep running with empty state even if the corrupt file cannot be moved.
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
private persistAskEdges(): void {
|
|
1827
|
+
const edges: PersistedAskEdge[] = Array.from(this.askEdges.values(), (edge) => ({
|
|
1828
|
+
messageId: edge.messageId,
|
|
1829
|
+
from: edge.from,
|
|
1830
|
+
to: edge.to,
|
|
1831
|
+
createdAt: edge.createdAt,
|
|
1832
|
+
expiresAt: edge.expiresAt,
|
|
1833
|
+
state: edge.state,
|
|
1834
|
+
}));
|
|
1835
|
+
writeDurableJson(ASK_STATE_PATH, { version: 1, edges });
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
private countAskEdgesFrom(sessionId: string): number {
|
|
1839
|
+
let count = 0;
|
|
1840
|
+
for (const edge of this.askEdges.values()) {
|
|
1841
|
+
if (edge.from === sessionId) {
|
|
1842
|
+
count += 1;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
return count;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
private countPendingDeliveriesFrom(sessionId: string): number {
|
|
1849
|
+
let count = 0;
|
|
1850
|
+
for (const delivery of this.pendingDeliveries.values()) {
|
|
1851
|
+
if (delivery.from === sessionId) {
|
|
1852
|
+
count += 1;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
return count;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
private acknowledgePendingDelivery(deliveryId: string, sessionId: string, socket: net.Socket): void {
|
|
1859
|
+
const pending = this.pendingDeliveries.get(deliveryId);
|
|
1860
|
+
if (!pending || pending.to !== sessionId || pending.recipientSocket !== socket) {
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
const sender = this.sessions.get(pending.from);
|
|
1864
|
+
const recipient = this.sessions.get(pending.to);
|
|
1865
|
+
if (
|
|
1866
|
+
!sender
|
|
1867
|
+
|| !recipient
|
|
1868
|
+
|| this.authorizationEpoch(sender.info) !== pending.fromGeneration
|
|
1869
|
+
|| this.authorizationEpoch(recipient.info) !== pending.toGeneration
|
|
1870
|
+
|| !this.isAuthorized(pending.from, pending.action, pending.to, pending.controlKind === undefined
|
|
1871
|
+
? {}
|
|
1872
|
+
: { controlKind: pending.controlKind, correlated: false })
|
|
1873
|
+
) {
|
|
1874
|
+
this.failPendingDelivery(deliveryId, "SESSION_NOT_FOUND", "Delivery authorization changed before acknowledgement");
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
clearTimeout(pending.timeout);
|
|
1879
|
+
this.pendingDeliveries.delete(deliveryId);
|
|
1880
|
+
this.pendingDeliveryKeys.delete(pending.key);
|
|
1881
|
+
if (pending.message.replyTo) {
|
|
1882
|
+
this.removeAskEdge(this.askKey(pending.to, pending.message.replyTo));
|
|
1883
|
+
}
|
|
1884
|
+
const response = { type: "delivered" as const, messageId: pending.message.id, deliveryId };
|
|
1885
|
+
this.recentDeliveries.set(pending.key, {
|
|
1886
|
+
fingerprint: pending.fingerprint,
|
|
1887
|
+
from: pending.from,
|
|
1888
|
+
to: pending.to,
|
|
1889
|
+
action: pending.action,
|
|
1890
|
+
...(pending.controlKind === undefined ? {} : { controlKind: pending.controlKind }),
|
|
1891
|
+
fromGeneration: pending.fromGeneration,
|
|
1892
|
+
toGeneration: pending.toGeneration,
|
|
1893
|
+
retryable: false,
|
|
1894
|
+
response,
|
|
1895
|
+
expiresAt: Date.now() + RECENT_DELIVERY_TTL_MS,
|
|
1896
|
+
});
|
|
1897
|
+
if (sender.socket === pending.senderSocket) {
|
|
1898
|
+
writeMessage(sender.socket, response);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
private failPendingDelivery(deliveryId: string, code: DeliveryFailureCode, reason: string): void {
|
|
1903
|
+
const pending = this.pendingDeliveries.get(deliveryId);
|
|
1904
|
+
if (!pending) {
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
clearTimeout(pending.timeout);
|
|
1909
|
+
this.pendingDeliveries.delete(deliveryId);
|
|
1910
|
+
this.pendingDeliveryKeys.delete(pending.key);
|
|
1911
|
+
if (pending.message.expectsReply) {
|
|
1912
|
+
this.removeAskEdge(this.askKey(pending.from, pending.message.id), "delivery_failed", true);
|
|
1913
|
+
}
|
|
1914
|
+
const response = {
|
|
1915
|
+
type: "delivery_failed" as const,
|
|
1916
|
+
messageId: pending.message.id,
|
|
1917
|
+
accepted: true,
|
|
1918
|
+
code,
|
|
1919
|
+
reason,
|
|
1920
|
+
};
|
|
1921
|
+
this.recentDeliveries.set(pending.key, {
|
|
1922
|
+
fingerprint: pending.fingerprint,
|
|
1923
|
+
from: pending.from,
|
|
1924
|
+
to: pending.to,
|
|
1925
|
+
action: pending.action,
|
|
1926
|
+
...(pending.controlKind === undefined ? {} : { controlKind: pending.controlKind }),
|
|
1927
|
+
fromGeneration: pending.fromGeneration,
|
|
1928
|
+
toGeneration: pending.toGeneration,
|
|
1929
|
+
retryable: true,
|
|
1930
|
+
response,
|
|
1931
|
+
expiresAt: Date.now() + RECENT_DELIVERY_TTL_MS,
|
|
1932
|
+
});
|
|
1933
|
+
const sender = this.sessions.get(pending.from);
|
|
1934
|
+
if (sender?.socket === pending.senderSocket) {
|
|
1935
|
+
writeMessage(sender.socket, response);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
private clearPendingDeliveriesForSession(sessionId: string, socket: net.Socket): void {
|
|
1940
|
+
for (const delivery of Array.from(this.pendingDeliveries.values())) {
|
|
1941
|
+
if (delivery.to === sessionId && delivery.recipientSocket === socket) {
|
|
1942
|
+
this.failPendingDelivery(delivery.id, "RECIPIENT_DISCONNECTED", "Recipient disconnected before acknowledging the message");
|
|
1943
|
+
} else if (delivery.from === sessionId && delivery.senderSocket === socket) {
|
|
1944
|
+
this.failPendingDelivery(delivery.id, "SENDER_DISCONNECTED", "Sender disconnected before delivery was acknowledged");
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
private pruneRecentDeliveries(now = Date.now()): void {
|
|
1950
|
+
for (const [key, delivery] of this.recentDeliveries) {
|
|
1951
|
+
if (delivery.expiresAt <= now) {
|
|
1952
|
+
this.recentDeliveries.delete(key);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
private findSessions(actorId: string, nameOrId: string): ConnectedSession[] {
|
|
1958
|
+
// Global exact full ID — crosses scopes
|
|
1959
|
+
const byId = this.sessions.get(nameOrId);
|
|
1960
|
+
if (byId) {
|
|
1961
|
+
return [byId];
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// Name and prefix resolution is restricted to the actor's scope
|
|
1965
|
+
const actor = this.sessions.get(actorId);
|
|
1966
|
+
if (!actor) return [];
|
|
1967
|
+
const sameScope = Array.from(this.sessions.values()).filter((session) =>
|
|
1968
|
+
sameIntercomScope(actor.scopeId, session.scopeId)
|
|
1969
|
+
);
|
|
1970
|
+
const lowerName = nameOrId.toLowerCase();
|
|
1971
|
+
const byName = sameScope.filter(session => session.info.name?.toLowerCase() === lowerName);
|
|
1972
|
+
if (byName.length > 0) {
|
|
1973
|
+
return byName;
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
return sameScope.filter((session) => session.info.id.startsWith(nameOrId));
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
private shutdown(): void {
|
|
1980
|
+
console.log("Broker shutting down");
|
|
1981
|
+
if (this.expiryTimer) {
|
|
1982
|
+
clearInterval(this.expiryTimer);
|
|
1983
|
+
this.expiryTimer = null;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
for (const session of this.sessions.values()) {
|
|
1987
|
+
session.socket.end();
|
|
1988
|
+
}
|
|
1989
|
+
this.sessions.clear();
|
|
1990
|
+
for (const delivery of this.pendingDeliveries.values()) {
|
|
1991
|
+
clearTimeout(delivery.timeout);
|
|
1992
|
+
}
|
|
1993
|
+
this.pendingDeliveries.clear();
|
|
1994
|
+
this.pendingDeliveryKeys.clear();
|
|
1995
|
+
for (const edge of this.askEdges.values()) {
|
|
1996
|
+
clearTimeout(edge.timeout);
|
|
1997
|
+
}
|
|
1998
|
+
this.askEdges.clear();
|
|
1999
|
+
const ownsBroker = hasBrokerOwnership(OWNER_PATH);
|
|
2000
|
+
if (ownsBroker && typeof LISTEN_TARGET === "string" && process.platform !== "win32") {
|
|
2001
|
+
for (const socketPath of [LISTEN_TARGET, REMOTE_LISTEN_TARGET]) {
|
|
2002
|
+
try {
|
|
2003
|
+
unlinkSync(socketPath);
|
|
2004
|
+
} catch {
|
|
2005
|
+
// The socket may already be gone if shutdown started after a disconnect.
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
if (ownsBroker) {
|
|
2010
|
+
try {
|
|
2011
|
+
unlinkSync(PORT_PATH);
|
|
2012
|
+
} catch {
|
|
2013
|
+
// The TCP endpoint file only exists when opt-in TCP transport is active.
|
|
2014
|
+
}
|
|
2015
|
+
try {
|
|
2016
|
+
unlinkSync(PID_PATH);
|
|
2017
|
+
} catch {
|
|
2018
|
+
// The PID file may already be gone if startup never completed.
|
|
2019
|
+
}
|
|
2020
|
+
releaseBrokerOwnership(OWNER_PATH);
|
|
2021
|
+
}
|
|
2022
|
+
this.server.close();
|
|
2023
|
+
this.remoteServer?.close();
|
|
2024
|
+
process.exit(0);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
new IntercomBroker().start();
|