@egoai/platform 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/monitor.ts ADDED
@@ -0,0 +1,591 @@
1
+ /**
2
+ * startAccount() — the long-lived task for the platform channel.
3
+ *
4
+ * The gateway ChannelManager calls this and auto-restarts it with backoff
5
+ * if it exits (WS close, abort, error).
6
+ *
7
+ * Flow:
8
+ * 1. Open loopback WS to the gateway's own local port
9
+ * 2. Buffer the connect.challenge from the gateway
10
+ * 3. Dial platform WS server
11
+ * 4. Send pre-handshake frame (delivers gateway token)
12
+ * 5. Forward the buffered challenge to platform
13
+ * 6. Manually relay the handshake (connect RPC → hello-ok)
14
+ * 7. Switch to generic bidirectional frame relay
15
+ *
16
+ * The buffered handshake is critical: the gateway has a short timeout for
17
+ * unauthenticated connections. By buffering the challenge before connecting
18
+ * to platform, we ensure the handshake completes before the timeout.
19
+ */
20
+
21
+ import { randomUUID } from "node:crypto";
22
+ import type { ChannelGatewayContext, OpenClawConfig, PluginLogger } from "openclaw/plugin-sdk";
23
+ import { resolveGatewayToken, resolveGatewayUrl } from "./config.js";
24
+ import type { PlatformAccount } from "./config.js";
25
+ import { handleWorkspaceLs, handleWorkspaceRead, handleWorkspaceWrite } from "./workspace.js";
26
+ import {
27
+ handlePlatformChatSend,
28
+ handlePlatformChatSendStream,
29
+ handlePlatformChatHistory,
30
+ handlePlatformChatAbort,
31
+ handlePlatformChatReset,
32
+ handlePlatformSessionResolve,
33
+ } from "./chat.js";
34
+ import type { RpcRequest, RpcResponse, WorkspaceLsParams, WorkspaceReadParams, WorkspaceWriteParams } from "./types.js";
35
+ import fs from "node:fs/promises";
36
+ import path from "node:path";
37
+ import { WebSocket, type MessageEvent as UndiciMessageEvent, type CloseEvent as UndiciCloseEvent, type ErrorEvent as UndiciErrorEvent } from 'undici';
38
+
39
+ /** Schemes permitted for the platform WebSocket URL. */
40
+ const ALLOWED_WS_SCHEMES = new Set(["ws:", "wss:"]);
41
+
42
+ /** Loopback addresses where plaintext ws:// is acceptable. */
43
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
44
+
45
+ /** Delay between automatic reconnect attempts. */
46
+ const RETRY_DELAY_MS = 60_000;
47
+
48
+ /**
49
+ * Callback that skips the current retry delay and reconnects immediately.
50
+ * Non-null only while a retry wait is in progress.
51
+ */
52
+ let pendingReconnect: (() => void) | null = null;
53
+
54
+ export type ConnectionStatus =
55
+ | { state: "disconnected" }
56
+ | { state: "connecting" }
57
+ | { state: "connected" }
58
+ | { state: "retrying"; retryingAt: number; lastError: string };
59
+
60
+ let connectionStatus: ConnectionStatus = { state: "disconnected" };
61
+
62
+ export function getConnectionStatus(): ConnectionStatus {
63
+ return connectionStatus;
64
+ }
65
+
66
+ /**
67
+ * Skip the pending retry delay and reconnect immediately.
68
+ * Returns true if a retry was in progress.
69
+ */
70
+ export function triggerReconnect(): boolean {
71
+ if (!pendingReconnect) return false;
72
+ pendingReconnect();
73
+ return true;
74
+ }
75
+
76
+ /** Extract a human-readable detail string from a WebSocket error event. */
77
+ function formatWsError(ev: UndiciErrorEvent): string {
78
+ const parts: string[] = [];
79
+ if (typeof ev.message === "string" && ev.message) parts.push(ev.message);
80
+ if (ev.error) parts.push(String(ev.error));
81
+ return parts.length ? parts.join(" — ") : `type=${ev.type}`;
82
+ }
83
+
84
+ /**
85
+ * Wait up to `delayMs` before resolving, but resolve early if:
86
+ * - `signal` is aborted (silently exits retry loop), or
87
+ * - the account's reconnect trigger is invoked.
88
+ */
89
+ function waitForRetry(delayMs: number, signal: AbortSignal): Promise<void> {
90
+ return new Promise<void>((resolve) => {
91
+ let settled = false;
92
+ const done = () => {
93
+ if (settled) return;
94
+ settled = true;
95
+ clearTimeout(timer);
96
+ pendingReconnect = null;
97
+ signal.removeEventListener("abort", onAbort);
98
+ resolve();
99
+ };
100
+ const timer = setTimeout(done, delayMs);
101
+ const onAbort = () => done();
102
+ pendingReconnect = done;
103
+ signal.addEventListener("abort", onAbort, { once: true });
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Validate and parse a WebSocket URL.
109
+ * Rejects non-ws(s) schemes to prevent SSRF against HTTP/internal services.
110
+ */
111
+ export function validateWsUrl(raw: string, label: string): URL {
112
+ let url: URL;
113
+ try {
114
+ url = new URL(raw);
115
+ } catch {
116
+ throw new Error(`platform: invalid ${label} URL: ${raw}`);
117
+ }
118
+
119
+ if (!ALLOWED_WS_SCHEMES.has(url.protocol)) {
120
+ throw new Error(
121
+ `platform: ${label} URL must use ws:// or wss:// (got ${url.protocol})`,
122
+ );
123
+ }
124
+
125
+ return url;
126
+ }
127
+
128
+ /**
129
+ * Warn if a token is about to be sent over a plaintext connection
130
+ * to a non-loopback host.
131
+ */
132
+ export function checkPlaintextToken(url: URL, logger?: PluginLogger): void {
133
+ if (url.protocol === "wss:") return; // encrypted — fine
134
+ if (LOOPBACK_HOSTS.has(url.hostname)) return; // local dev — acceptable
135
+
136
+ logger?.warn?.(
137
+ `platform: sending token over plaintext ws:// to ${url.hostname} — ` +
138
+ `use wss:// in production to protect credentials`,
139
+ );
140
+ }
141
+
142
+ /** Format a WebSocket CloseEvent into a human-readable reason string. */
143
+ function formatCloseEvent(ev: UndiciCloseEvent): string {
144
+ return ev.reason ? `${ev.code}: ${ev.reason}` : `code ${ev.code}`;
145
+ }
146
+
147
+ /**
148
+ * Connect to a WS server with an abort signal.
149
+ * Returns a WebSocket in OPEN state or throws.
150
+ * If the server closes the connection before or immediately after open,
151
+ * rejects with the close code and reason from the server.
152
+ */
153
+ function connectWithAbort(url: string, signal: AbortSignal, timeoutMs = 10_000): Promise<WebSocket> {
154
+ return new Promise<WebSocket>((resolve, reject) => {
155
+ if (signal.aborted) {
156
+ reject(new Error("aborted before connect"));
157
+ return;
158
+ }
159
+
160
+ const ws = new WebSocket(url);
161
+ let opened = false;
162
+
163
+ const settle = (fn: () => void) => {
164
+ clearTimeout(timer);
165
+ signal.removeEventListener("abort", onAbort);
166
+ fn();
167
+ };
168
+
169
+ const timer = setTimeout(() => {
170
+ ws.close();
171
+ settle(() => reject(new Error(`WebSocket connect timeout after ${timeoutMs}ms (${url})`)));
172
+ }, timeoutMs);
173
+
174
+ const onAbort = () => {
175
+ ws.close();
176
+ settle(() => reject(new Error("aborted during connect")));
177
+ };
178
+ signal.addEventListener("abort", onAbort, { once: true });
179
+
180
+ ws.addEventListener("open", () => {
181
+ opened = true;
182
+ settle(() => resolve(ws));
183
+ }, { once: true });
184
+
185
+ ws.addEventListener("error", (ev) => {
186
+ settle(() => reject(new Error(`WebSocket connect error: ${formatWsError(ev)}`)));
187
+ }, { once: true });
188
+
189
+ // Capture server-sent close reasons (e.g. service restarting) that arrive
190
+ // before the open event. If open already fired, waitForMessage handles it.
191
+ ws.addEventListener("close", (ev) => {
192
+ if (opened) return;
193
+ settle(() => reject(new Error(`WebSocket closed during connect (${formatCloseEvent(ev)})`)));
194
+ }, { once: true });
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Wait for the next message from a WebSocket.
200
+ * Rejects on close, error, abort, or timeout.
201
+ */
202
+ function waitForMessage(
203
+ ws: WebSocket,
204
+ signal: AbortSignal,
205
+ timeoutMs = 10_000,
206
+ ): Promise<string> {
207
+ return new Promise<string>((resolve, reject) => {
208
+ const timer = setTimeout(() => {
209
+ cleanup();
210
+ reject(new Error("timeout waiting for message"));
211
+ }, timeoutMs);
212
+
213
+ const cleanup = () => {
214
+ clearTimeout(timer);
215
+ ws.removeEventListener("message", onMessage);
216
+ ws.removeEventListener("close", onClose);
217
+ ws.removeEventListener("error", onError);
218
+ signal.removeEventListener("abort", onAbort);
219
+ };
220
+
221
+ const onMessage = (ev: UndiciMessageEvent) => {
222
+ cleanup();
223
+ resolve(typeof ev.data === "string" ? ev.data : String(ev.data));
224
+ };
225
+ const onClose = (ev: UndiciCloseEvent) => {
226
+ cleanup();
227
+ reject(new Error(`WebSocket closed while waiting for message (${formatCloseEvent(ev)})`));
228
+ };
229
+ const onError = (ev: UndiciErrorEvent) => {
230
+ cleanup();
231
+ reject(new Error(`WebSocket error: ${formatWsError(ev)}`));
232
+ };
233
+ const onAbort = () => {
234
+ cleanup();
235
+ reject(new Error("aborted"));
236
+ };
237
+
238
+ ws.addEventListener("message", onMessage, { once: true });
239
+ ws.addEventListener("close", onClose, { once: true });
240
+ ws.addEventListener("error", onError, { once: true });
241
+ signal.addEventListener("abort", onAbort, { once: true });
242
+ });
243
+ }
244
+
245
+ /** Resolve the openclaw.json config path. */
246
+ function resolveConfigPath(): string {
247
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
248
+ return path.join(home, ".openclaw", "openclaw.json");
249
+ }
250
+
251
+ /**
252
+ * Re-read the agents list from the on-disk config file.
253
+ * Falls back to the in-memory config if the file read fails.
254
+ */
255
+ async function freshAgentsConfig(
256
+ fallbackCfg: OpenClawConfig,
257
+ logger?: PluginLogger,
258
+ ): Promise<{ agents: Array<{ id: string; workspace?: string }>; defaultWorkspace?: string }> {
259
+ try {
260
+ const raw = await fs.readFile(resolveConfigPath(), "utf8");
261
+ const diskCfg = JSON.parse(raw) as OpenClawConfig;
262
+ return {
263
+ agents: diskCfg.agents?.list ?? [],
264
+ defaultWorkspace: diskCfg.agents?.defaults?.workspace?.trim() || undefined,
265
+ };
266
+ } catch (err) {
267
+ logger?.warn?.(`sideclaw: failed to re-read config from disk, using in-memory snapshot: ${err}`);
268
+ return {
269
+ agents: fallbackCfg.agents?.list ?? [],
270
+ defaultWorkspace: fallbackCfg.agents?.defaults?.workspace?.trim() || undefined,
271
+ };
272
+ }
273
+ }
274
+
275
+ /** Sends an RPC call to the gateway WS and resolves with the payload or throws on error. */
276
+ type GatewayForwarder = (method: string, params: Record<string, unknown>) => Promise<unknown>;
277
+
278
+ /** Context passed to every plugin RPC handler. */
279
+ type PluginRpcContext = {
280
+ cfg: OpenClawConfig;
281
+ logger?: PluginLogger;
282
+ /** Forward an RPC call to the gateway and await its response. */
283
+ forwardToGateway: GatewayForwarder;
284
+ /** Send a raw frame to the platform WS (used for streaming chunk notifications). */
285
+ sendToPlatform: (frame: unknown) => void;
286
+ };
287
+
288
+ /** A handler for a plugin-intercepted RPC method. Returns a ready-to-send RpcResponse. */
289
+ type PluginRpcHandler = (frame: RpcRequest, ctx: PluginRpcContext) => Promise<RpcResponse>;
290
+
291
+ function makeHandler<P>(
292
+ fn: (
293
+ agents: Array<{ id: string; workspace?: string }>,
294
+ params: P,
295
+ logger?: PluginLogger,
296
+ defaultWorkspace?: string,
297
+ ) => Promise<{ ok: true; payload: unknown } | { ok: false; error: string }>,
298
+ ): PluginRpcHandler {
299
+ return async (frame, ctx) => {
300
+ const { agents: agentsList, defaultWorkspace } = await freshAgentsConfig(ctx.cfg, ctx.logger);
301
+ const result = await fn(agentsList, frame.params as P, ctx.logger, defaultWorkspace);
302
+ return result.ok
303
+ ? { type: "res", id: frame.id, ok: true, payload: result.payload }
304
+ : { type: "res", id: frame.id, ok: false, error: { message: result.error } };
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Plugin-intercepted RPC methods.
310
+ * Add entries here to handle new methods locally without forwarding to the gateway.
311
+ *
312
+ * Workspace methods are handled entirely locally (no gateway call).
313
+ * Platform chat methods build session keys and forward to the gateway.
314
+ */
315
+ const PLUGIN_HANDLERS: Record<string, PluginRpcHandler> = {
316
+ // Workspace operations — handled locally
317
+ "workspace.read": makeHandler<WorkspaceReadParams>(handleWorkspaceRead),
318
+ "workspace.write": makeHandler<WorkspaceWriteParams>(handleWorkspaceWrite),
319
+ "workspace.ls": makeHandler<WorkspaceLsParams>(handleWorkspaceLs),
320
+
321
+ // Chat interface — translates platform user context into openclaw session keys,
322
+ // then forwards to the gateway. Mirrors the Telegram channel session pattern.
323
+ "platform.chat.send": (frame, ctx) =>
324
+ handlePlatformChatSend(frame, { forwardToGateway: ctx.forwardToGateway, cfg: ctx.cfg, logger: ctx.logger, sendToPlatform: ctx.sendToPlatform }),
325
+ "platform.chat.send.stream": (frame, ctx) =>
326
+ handlePlatformChatSendStream(frame, { forwardToGateway: ctx.forwardToGateway, cfg: ctx.cfg, logger: ctx.logger, sendToPlatform: ctx.sendToPlatform }),
327
+ "platform.chat.history": (frame, ctx) =>
328
+ handlePlatformChatHistory(frame, { forwardToGateway: ctx.forwardToGateway, cfg: ctx.cfg, logger: ctx.logger }),
329
+ "platform.chat.abort": (frame, ctx) =>
330
+ handlePlatformChatAbort(frame, { forwardToGateway: ctx.forwardToGateway, cfg: ctx.cfg, logger: ctx.logger }),
331
+ "platform.chat.reset": (frame, ctx) =>
332
+ handlePlatformChatReset(frame, { forwardToGateway: ctx.forwardToGateway, cfg: ctx.cfg, logger: ctx.logger }),
333
+
334
+ // Session key resolution — no gateway call, just returns the computed key.
335
+ "platform.session.resolve": (frame, ctx) =>
336
+ handlePlatformSessionResolve(frame, { forwardToGateway: ctx.forwardToGateway, cfg: ctx.cfg, logger: ctx.logger }),
337
+ };
338
+
339
+ /**
340
+ * Relay frames bidirectionally between two WebSockets.
341
+ *
342
+ * Messages from `a` (platform/bot-runner) heading to `b` (gateway) are
343
+ * logged and checked against PLUGIN_HANDLERS. Matching methods are handled
344
+ * locally; everything else is forwarded verbatim.
345
+ *
346
+ * @param cfg - Full gateway config. Contains `agents.defaults.workspace` and
347
+ * optionally `agents.list` for agent-specific overrides.
348
+ * @param logger - Optional logger for request logging and handler operations.
349
+ */
350
+ /** Timeout for plugin-initiated gateway RPC requests. */
351
+ const GATEWAY_REQUEST_TIMEOUT_MS = 30_000;
352
+
353
+ function relayFrames(
354
+ a: WebSocket,
355
+ b: WebSocket,
356
+ signal: AbortSignal,
357
+ cfg: OpenClawConfig,
358
+ logger?: PluginLogger,
359
+ ): Promise<void> {
360
+ // Pending requests that the plugin (not the platform) sent to the gateway.
361
+ // Keyed by the plugin-generated request ID so bToA can intercept responses.
362
+ const pendingGatewayRequests = new Map<string, (res: RpcResponse) => void>();
363
+
364
+ const forwardToGateway: GatewayForwarder = (method, params) =>
365
+ new Promise((resolve, reject) => {
366
+ const id = randomUUID();
367
+ const timer = setTimeout(() => {
368
+ pendingGatewayRequests.delete(id);
369
+ reject(new Error(`gateway request timed out: ${method}`));
370
+ }, GATEWAY_REQUEST_TIMEOUT_MS);
371
+
372
+ pendingGatewayRequests.set(id, (res) => {
373
+ clearTimeout(timer);
374
+ pendingGatewayRequests.delete(id);
375
+ if (res.ok) {
376
+ resolve(res.payload);
377
+ } else {
378
+ reject(new Error(res.error?.message ?? `gateway error for ${method}`));
379
+ }
380
+ });
381
+
382
+ const reqFrame: RpcRequest = { type: "req", id, method, params };
383
+ try {
384
+ b.send(JSON.stringify(reqFrame));
385
+ } catch (err) {
386
+ clearTimeout(timer);
387
+ pendingGatewayRequests.delete(id);
388
+ reject(err);
389
+ }
390
+ });
391
+
392
+ const pluginCtx: PluginRpcContext = {
393
+ cfg,
394
+ logger,
395
+ forwardToGateway,
396
+ sendToPlatform: (frame) => {
397
+ try { a.send(JSON.stringify(frame)); } catch { /* connection closed */ }
398
+ },
399
+ };
400
+
401
+ return new Promise<void>((resolve) => {
402
+ let resolved = false;
403
+ const done = () => {
404
+ if (resolved) return;
405
+ resolved = true;
406
+ // Reject all pending gateway requests so callers don't hang.
407
+ for (const cb of pendingGatewayRequests.values()) {
408
+ cb({ type: "res", id: "", ok: false, error: { message: "connection closed" } });
409
+ }
410
+ pendingGatewayRequests.clear();
411
+ a.removeEventListener("message", aToB);
412
+ b.removeEventListener("message", bToA);
413
+ a.removeEventListener("close", onClose);
414
+ b.removeEventListener("close", onClose);
415
+ signal.removeEventListener("abort", onAbort);
416
+ try { a.close(); } catch { /* already closed */ }
417
+ try { b.close(); } catch { /* already closed */ }
418
+ resolve();
419
+ };
420
+
421
+ const aToB = (ev: UndiciMessageEvent) => {
422
+ const raw = typeof ev.data === "string" ? ev.data : String(ev.data);
423
+
424
+ try {
425
+ const frame = JSON.parse(raw) as RpcRequest;
426
+ if (frame.type === "req") {
427
+ logger?.info(`platform: rpc request id=${frame.id} method=${frame.method}`);
428
+ const handler = PLUGIN_HANDLERS[frame.method];
429
+ if (handler) {
430
+ handler(frame, pluginCtx)
431
+ .then((resp) => {
432
+ try { a.send(JSON.stringify(resp)); } catch { done(); }
433
+ })
434
+ .catch((err) => {
435
+ const resp: RpcResponse = {
436
+ type: "res", id: frame.id, ok: false,
437
+ error: { message: String(err) },
438
+ };
439
+ try { a.send(JSON.stringify(resp)); } catch { done(); }
440
+ });
441
+ return; // intercepted — don't forward to gateway
442
+ }
443
+ } else if (frame.type === "error") {
444
+ const frame = JSON.parse(raw) as { type?: unknown; message?: unknown };
445
+ logger?.error(`platform: gateway error — ${frame.message ?? "(no message)"}`);
446
+ return;
447
+ }
448
+
449
+ } catch { /* not a parseable RPC — fall through */ }
450
+ try { b.send(ev.data); } catch { done(); }
451
+ };
452
+
453
+ const bToA = (ev: UndiciMessageEvent) => {
454
+ const raw = typeof ev.data === "string" ? ev.data : String(ev.data);
455
+
456
+ // Check if this response belongs to a plugin-initiated gateway request.
457
+ try {
458
+ const frame = JSON.parse(raw) as RpcResponse;
459
+ if (frame.type === "res" && frame.id) {
460
+ const pending = pendingGatewayRequests.get(frame.id);
461
+ if (pending) {
462
+ pending(frame);
463
+ return; // consumed — don't forward to platform
464
+ }
465
+ }
466
+ } catch { /* not parseable — fall through */ }
467
+
468
+ try { a.send(ev.data); } catch { done(); }
469
+ };
470
+ const onClose = () => done();
471
+ const onAbort = () => done();
472
+
473
+ a.addEventListener("message", aToB);
474
+ b.addEventListener("message", bToA);
475
+ a.addEventListener("close", onClose, { once: true });
476
+ b.addEventListener("close", onClose, { once: true });
477
+ signal.addEventListener("abort", onAbort, { once: true });
478
+ });
479
+ }
480
+
481
+ /**
482
+ * One connection attempt: buffer challenge, dial platform, relay handshake, then relay frames.
483
+ */
484
+ async function runSession(ctx: ChannelGatewayContext<PlatformAccount>): Promise<void> {
485
+ const { platformUrl } = ctx.account;
486
+ const gatewayToken = resolveGatewayToken(ctx.cfg);
487
+ const platformKey = ctx.account.platformKey;
488
+ if (!platformKey) {
489
+ throw new Error(
490
+ "platform: platformKey is required — generate one from Settings > Gateway in the Platform web app",
491
+ );
492
+ }
493
+ const identityToken = platformKey;
494
+ const gatewayUrl = resolveGatewayUrl(ctx.cfg);
495
+
496
+ // Validate URLs before connecting
497
+ const platformParsed = validateWsUrl(platformUrl, "platformUrl");
498
+ validateWsUrl(gatewayUrl, "gatewayUrl");
499
+
500
+ // Warn if sending token over plaintext to a remote host
501
+ checkPlaintextToken(platformParsed, ctx.log);
502
+
503
+ connectionStatus = { state: "connecting" };
504
+
505
+ // 1. Connect to gateway FIRST — it sends connect.challenge immediately
506
+ ctx.log?.info("platform: [1] connecting to gateway");
507
+ const gatewayWs = await connectWithAbort(gatewayUrl, ctx.abortSignal);
508
+ ctx.log?.info("platform: [1] gateway connected");
509
+
510
+ // 2. Buffer the connect.challenge
511
+ ctx.log?.info("platform: [2] waiting for connect.challenge from gateway");
512
+ const challengeRaw = await waitForMessage(gatewayWs, ctx.abortSignal);
513
+ ctx.log?.info("platform: [2] challenge buffered");
514
+
515
+ // 3. Dial platform
516
+ // TODO: to prevent some malicious skill from changing config to connect
517
+ // to their platform and taking control of our user's OpenClaw we should validate
518
+ // the TLS certificate of the platformUrl server is owned by us.
519
+ ctx.log?.info(`platform: [3] dialing platform at ${platformUrl}`);
520
+ const platformWs = await connectWithAbort(platformUrl, ctx.abortSignal);
521
+ ctx.log?.info("platform: [3] platform connected");
522
+ ctx.setStatus({ accountId: ctx.accountId, connected: false, lastError: null });
523
+
524
+ // 4. Send pre-handshake — delivers identity token (pairing or gateway) for routing,
525
+ // plus the gateway token so platform can sign the handshake correctly.
526
+ ctx.log?.info("platform: [4] sending pre-handshake");
527
+ platformWs.send(
528
+ JSON.stringify({
529
+ type: "pre-handshake",
530
+ version: 1,
531
+ token: identityToken,
532
+ gatewayToken,
533
+ }),
534
+ );
535
+
536
+ // 5. Forward the buffered challenge to platform
537
+ ctx.log?.info("platform: [5] forwarding challenge to platform");
538
+ platformWs.send(challengeRaw);
539
+
540
+ // 6. Relay the handshake manually:
541
+ // platform sends connect RPC → forward to gateway
542
+ ctx.log?.info("platform: [6a] waiting for connect RPC from platform");
543
+ const connectRaw = await waitForMessage(platformWs, ctx.abortSignal);
544
+ ctx.log?.info("platform: [6a] forwarding connect RPC to gateway");
545
+ gatewayWs.send(connectRaw);
546
+
547
+ // gateway sends hello-ok → forward to platform
548
+ ctx.log?.info("platform: [6b] waiting for hello-ok from gateway");
549
+ const helloRaw = await waitForMessage(gatewayWs, ctx.abortSignal);
550
+ ctx.log?.info("platform: [6b] forwarding hello-ok to platform");
551
+ platformWs.send(helloRaw);
552
+
553
+ connectionStatus = { state: "connected" };
554
+ ctx.setStatus({ accountId: ctx.accountId, connected: true });
555
+ ctx.log?.info("platform: handshake complete, ready");
556
+
557
+ // 7. Switch to generic bidirectional frame relay
558
+ // All GatewaySession RPC calls flow through from here.
559
+ ctx.log?.info("platform: [7] entering relay loop");
560
+ await relayFrames(platformWs, gatewayWs, ctx.abortSignal, ctx.cfg, ctx.log);
561
+ }
562
+
563
+ /**
564
+ * Long-lived task: run `runSession` in a retry loop.
565
+ * On failure, logs the full error and waits RETRY_DELAY_MS before
566
+ * reconnecting. The wait can be skipped early via `triggerReconnect`.
567
+ */
568
+ export async function startAccount(ctx: ChannelGatewayContext<PlatformAccount>): Promise<void> {
569
+ try {
570
+ while (!ctx.abortSignal.aborted) {
571
+ let lastError: string | null = null;
572
+ try {
573
+ await runSession(ctx);
574
+ ctx.log?.info("platform: session ended, reconnecting in " + RETRY_DELAY_MS / 1000 + "s");
575
+ } catch (err) {
576
+ if (ctx.abortSignal.aborted) return;
577
+ const msg = err instanceof Error ? `${err.message}${err.cause ? ` (cause: ${err.cause})` : ""}` : String(err);
578
+ ctx.log?.error(`platform: connection failed — ${msg}${err instanceof Error && err.stack ? `\n${err.stack}` : ""}`);
579
+ ctx.setStatus({ accountId: ctx.accountId, connected: false, lastError: msg });
580
+ lastError = msg;
581
+ ctx.log?.info(`platform: retrying in ${RETRY_DELAY_MS / 1000}s (or use /platform-reconnect to retry now)`);
582
+ }
583
+ if (ctx.abortSignal.aborted) return;
584
+ const retryingAt = Date.now() + RETRY_DELAY_MS;
585
+ connectionStatus = { state: "retrying", retryingAt, lastError: lastError ?? "disconnected" };
586
+ await waitForRetry(RETRY_DELAY_MS, ctx.abortSignal);
587
+ }
588
+ } finally {
589
+ connectionStatus = { state: "disconnected" };
590
+ }
591
+ }