salt-agent-sdk 0.1.0 → 0.19.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.
Files changed (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +941 -30
  3. package/dist/actions.d.ts +21 -5
  4. package/dist/actions.d.ts.map +1 -1
  5. package/dist/actions.js +673 -54
  6. package/dist/actions.js.map +1 -1
  7. package/dist/ask.d.ts +83 -0
  8. package/dist/ask.d.ts.map +1 -0
  9. package/dist/ask.js +215 -0
  10. package/dist/ask.js.map +1 -0
  11. package/dist/chatPrivacy.d.ts +34 -0
  12. package/dist/chatPrivacy.d.ts.map +1 -0
  13. package/dist/chatPrivacy.js +60 -0
  14. package/dist/chatPrivacy.js.map +1 -0
  15. package/dist/client.d.ts +3123 -35
  16. package/dist/client.d.ts.map +1 -1
  17. package/dist/client.js +1267 -143
  18. package/dist/client.js.map +1 -1
  19. package/dist/config.d.ts +16 -6
  20. package/dist/config.d.ts.map +1 -1
  21. package/dist/config.js +16 -5
  22. package/dist/config.js.map +1 -1
  23. package/dist/crypto.d.ts +62 -0
  24. package/dist/crypto.d.ts.map +1 -1
  25. package/dist/crypto.js +121 -0
  26. package/dist/crypto.js.map +1 -1
  27. package/dist/delegations.d.ts +25 -8
  28. package/dist/delegations.d.ts.map +1 -1
  29. package/dist/delegations.js +67 -11
  30. package/dist/delegations.js.map +1 -1
  31. package/dist/device/client.d.ts +161 -0
  32. package/dist/device/client.d.ts.map +1 -0
  33. package/dist/device/client.js +601 -0
  34. package/dist/device/client.js.map +1 -0
  35. package/dist/device/host.d.ts +222 -0
  36. package/dist/device/host.d.ts.map +1 -0
  37. package/dist/device/host.js +891 -0
  38. package/dist/device/host.js.map +1 -0
  39. package/dist/device/http.d.ts +12 -0
  40. package/dist/device/http.d.ts.map +1 -0
  41. package/dist/device/http.js +124 -0
  42. package/dist/device/http.js.map +1 -0
  43. package/dist/device/index.d.ts +5 -0
  44. package/dist/device/index.d.ts.map +1 -0
  45. package/dist/device/index.js +27 -0
  46. package/dist/device/index.js.map +1 -0
  47. package/dist/device/protocol.d.ts +312 -0
  48. package/dist/device/protocol.d.ts.map +1 -0
  49. package/dist/device/protocol.js +435 -0
  50. package/dist/device/protocol.js.map +1 -0
  51. package/dist/identities.d.ts +9 -2
  52. package/dist/identities.d.ts.map +1 -1
  53. package/dist/identities.js +33 -3
  54. package/dist/identities.js.map +1 -1
  55. package/dist/identity.d.ts +155 -0
  56. package/dist/identity.d.ts.map +1 -0
  57. package/dist/identity.js +196 -0
  58. package/dist/identity.js.map +1 -0
  59. package/dist/identityShare.d.ts +129 -0
  60. package/dist/identityShare.d.ts.map +1 -0
  61. package/dist/identityShare.js +308 -0
  62. package/dist/identityShare.js.map +1 -0
  63. package/dist/ids.d.ts +11 -0
  64. package/dist/ids.d.ts.map +1 -0
  65. package/dist/ids.js +17 -0
  66. package/dist/ids.js.map +1 -0
  67. package/dist/index.d.ts +13 -0
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +13 -0
  70. package/dist/index.js.map +1 -1
  71. package/dist/reconcile.d.ts +25 -0
  72. package/dist/reconcile.d.ts.map +1 -0
  73. package/dist/reconcile.js +60 -0
  74. package/dist/reconcile.js.map +1 -0
  75. package/dist/recovery.d.ts +51 -0
  76. package/dist/recovery.d.ts.map +1 -0
  77. package/dist/recovery.js +59 -0
  78. package/dist/recovery.js.map +1 -0
  79. package/dist/register.d.ts +32 -0
  80. package/dist/register.d.ts.map +1 -0
  81. package/dist/register.js +65 -0
  82. package/dist/register.js.map +1 -0
  83. package/dist/scriptJob.d.ts +92 -0
  84. package/dist/scriptJob.d.ts.map +1 -0
  85. package/dist/scriptJob.js +270 -0
  86. package/dist/scriptJob.js.map +1 -0
  87. package/dist/sessions.d.ts +131 -0
  88. package/dist/sessions.d.ts.map +1 -0
  89. package/dist/sessions.js +240 -0
  90. package/dist/sessions.js.map +1 -0
  91. package/dist/socket.d.ts +115 -0
  92. package/dist/socket.d.ts.map +1 -0
  93. package/dist/socket.js +1038 -0
  94. package/dist/socket.js.map +1 -0
  95. package/dist/webhook.d.ts +627 -28
  96. package/dist/webhook.d.ts.map +1 -1
  97. package/dist/webhook.js +1388 -84
  98. package/dist/webhook.js.map +1 -1
  99. package/dist/work.d.ts +57 -0
  100. package/dist/work.d.ts.map +1 -0
  101. package/dist/work.js +213 -0
  102. package/dist/work.js.map +1 -0
  103. package/package.json +26 -4
package/dist/socket.js ADDED
@@ -0,0 +1,1038 @@
1
+ "use strict";
2
+ // K2 socket mode (design-fleet/runs/2026-09-17-distribution/LANES.md
3
+ // "Socket mode contract"), now PUSH, not poll (2026-09-22, owner: "DO NOT
4
+ // USE POLLING as a mechanic EVER"): an agent with no public URL -- OpenClaw,
5
+ // a local LangGraph script, Claude Code on a laptop -- can't receive a
6
+ // webhook POST. `createSocketClient` opens a real websocket to salt-api's
7
+ // Action Cable (`AgentUpdatesChannel`) and stays connected; salt-api PUSHES
8
+ // each envelope the instant it's written. An idle, caught-up agent makes
9
+ // ZERO requests -- there is no interval timer anywhere in this file.
10
+ //
11
+ // "The same handlers" is not a promise made by convention -- it's
12
+ // createDispatcher (webhook.ts), the one place both createWebhookServer and
13
+ // createSocketClient end up. A consumer switches an identity from webhook
14
+ // to socket mode by:
15
+ // 1. calling PATCH /api/v1/agents/delivery {mode:"socket"} once (or just
16
+ // leaving that identity's callback blank -- salt-api treats a blank
17
+ // callback as socket mode regardless of this setting), and
18
+ // 2. replacing `createWebhookServer(options).listen(port)` with
19
+ // `createSocketClient({...options, host, apiKey, agentId}).start()`.
20
+ // onMessage/onCardInteraction/onInvoicePaid/onChatOpened/onHandoffConfirmed/
21
+ // onHandoffReceived never change.
22
+ //
23
+ // WIRE PROTOCOL (Action Cable, `wss://<host>/cable`, the agent's own
24
+ // `api-key` on the handshake HEADER -- never a query param, the same
25
+ // ALB/CloudWatch-access-log-leak reasoning the REST api-key path already
26
+ // documents -- see salt-api's ApplicationCable::Connection and
27
+ // AgentUpdatesChannel):
28
+ // 1. Connect. Server sends {type:"welcome"}.
29
+ // 2. Subscribe: {command:"subscribe", identifier: JSON.stringify({channel:
30
+ // "AgentUpdatesChannel", after: <local cursor>})} -- `after` OMITTED
31
+ // entirely (not sent as 0) when there is no local cursor yet, so
32
+ // salt-api's own server-side ack applies instead of replaying from
33
+ // scratch (same convention the old poll client used).
34
+ // 3. Server replies {type:"confirm_subscription", identifier} or
35
+ // {type:"reject_subscription", identifier}, then REPLAYS the backlog
36
+ // from the resolved cursor as ordinary envelope frames -- the same
37
+ // shape as a poll row, wrapped as {identifier, message: {id,
38
+ // delivery_id, event, headers, body, created_at}} -- ending with
39
+ // {identifier, message: {type:"replay_done", cursor, more?}}. Live
40
+ // broadcasts arrive as that identical envelope shape, interleaved with
41
+ // (and continuing after) the replay.
42
+ // 4. {type:"ping", message:<unix ts>} arrives roughly every 3s -- the
43
+ // liveness signal. No ping for 30s means the connection is dead (a
44
+ // half-open TCP socket may never emit its own 'close') -- terminate
45
+ // and reconnect.
46
+ // 5. {type:"disconnect", reason, reconnect} means salt-api is closing this
47
+ // connection on purpose (e.g. a deploy) -- always reconnect regardless
48
+ // of the `reconnect` value; salt-api never asks a client to stay down
49
+ // for good.
50
+ //
51
+ // CURSOR PERSISTENCE (LANES.md, fix N8): the local cursorStore is written
52
+ // ONLY from a real poll-shaped response -- a `replay_done` frame's
53
+ // `cursor`, a backfill GET page's `cursor`, or the ack GET's `cursor` --
54
+ // NEVER from a live envelope frame's own `id` directly. Two different live
55
+ // broadcasts for the same agent have no cross-broadcast ordering guarantee
56
+ // (they can originate from two different Puma processes/transactions), so
57
+ // persisting from whichever arrived first risks writing a cursor that's
58
+ // ahead of one still in flight; on a later reconnect that row would never
59
+ // be replayed again (the server's ack only ever moves forward, never
60
+ // rewinds). Live frames are still DISPATCHED the moment they arrive (once
61
+ // this connection has caught up) -- this restriction is only about what
62
+ // gets written to disk / used to resume, never about delivering a message
63
+ // to onMessage.
64
+ //
65
+ // A ROW THAT FAILS VERIFICATION TRANSIENTLY (a network error fetching the
66
+ // signing key -- never a bad signature) is a harder case here than it was
67
+ // for the poll client: a poll simply re-fetches the same un-advanced row on
68
+ // its next round trip, but a websocket replay happens exactly once per
69
+ // connection -- there is no "poll again" to retry a single bad row without
70
+ // re-processing everything after it too. So this client does NOT halt the
71
+ // rest of the stream on a transient failure (that would block every later,
72
+ // perfectly good message behind one hiccup); instead it remembers the
73
+ // LOWEST id that failed transiently (pendingFailureId, below) and clamps
74
+ // every cursor persistence to stop just short of it, so the NEXT reconnect
75
+ // naturally re-replays from that row forward -- giving it another chance
76
+ // -- while dedupe silently no-ops whatever already succeeded the first
77
+ // time.
78
+ //
79
+ // ACK: after a batch of frames is processed (replay, backfill, or live),
80
+ // this client tells salt-api how far it got with a single, coalesced
81
+ // `GET /api/v1/agent/updates?after=<highest processed id>&timeout=0&limit=1`
82
+ // call -- there is no dedicated ack action on the channel; `after` on this
83
+ // endpoint already IS salt-api's ack (see AgentUpdatesController). This is
84
+ // event-driven: at most one ack in flight, and at most one more queued
85
+ // behind it for whatever arrived while it was out -- never a timer. Its
86
+ // only purpose is so a FRESH connection (a lost local cursorStore, or a
87
+ // process that has never held one) resumes near here instead of replaying
88
+ // up to 7 days of backlog; this client's own resume point still comes only
89
+ // from cursorStore, per the persistence rule above.
90
+ //
91
+ // BACKFILL: `replay_done.more: true` means AgentUpdatesChannel's own
92
+ // MAX_BACKLOG_REPLAY (500 rows) actually truncated the backlog. The socket
93
+ // alone cannot deliver more than that over the wire, so this client pages
94
+ // `GET /api/v1/agent/updates?after=<cursor>&timeout=0` (a real poll
95
+ // response has no such cap beyond its own `limit`) until a page comes back
96
+ // empty. This is the ONLY remaining use of the poll endpoint's `timeout`
97
+ // parameter, and it fires only when `more` says so -- never on an
98
+ // interval. Live frames that arrive WHILE backfill is running are buffered
99
+ // (never dropped) and drained -- in id order RELATIVE TO EACH OTHER, never
100
+ // interleaved with backfill's own dispatch order -- once the backfill loop
101
+ // empties out; DedupeStore makes any overlap between a buffered live frame
102
+ // and a backfilled row harmless either way.
103
+ //
104
+ // RECONNECT: on close (clean or not), a dead-ping timeout, or a rejected
105
+ // subscription, reconnect with exponential backoff (RECONNECT_MIN_DELAY_MS
106
+ // .. RECONNECT_MAX_DELAY_MS, jittered), resubscribing with whatever
107
+ // cursorStore now holds. A 429 on the handshake itself (the blanket
108
+ // api-key/ip ceiling in rack_attack.rb covers /cable the same as any other
109
+ // path) honours Retry-After exactly like the old poll client did,
110
+ // overriding the backoff for that one wait.
111
+ //
112
+ // FRAME ORDERING vs. HANDLERS THAT WAIT (fix, 2026-09-30): envelope frames are
113
+ // still verified, deduped and dispatched strictly in arrival order through
114
+ // one serial queue, and a handler occupies that queue until it returns. A
115
+ // handler that does `await ctx.ask(...)` therefore blocks the queue while
116
+ // the answer it waits for (a card tap, or the person's typed reply) is a
117
+ // frame queued BEHIND it -- and before this fix the pings sat there too, so
118
+ // the 30s watchdog killed the connection, the reconnect replayed the same
119
+ // chat_opened, and every restart posted the question again. Two rules now
120
+ // hold, and the ordering promise above is unchanged for everything else:
121
+ // (a) Action Cable control frames (welcome, ping, confirm/reject,
122
+ // disconnect) are handled the instant they arrive, never queued: they
123
+ // carry no ordering relative to envelopes and a busy handler must not
124
+ // starve the liveness watchdog.
125
+ // (b) An envelope that ANSWERS a pending ctx.ask (a card_interaction on
126
+ // its card, or the named person's plain reply -- Dispatcher.resolveAnswer)
127
+ // is verified and resolved at arrival, ahead of the handler queue, and
128
+ // is then consumed (deduped, acked) instead of queued. Everything else,
129
+ // including every envelope that does not answer an ask, still goes
130
+ // through the queue in order. Arrival itself is a second, tiny serial
131
+ // chain (verify + maybe decrypt only, never a handler) so relative
132
+ // order into the handler queue is preserved.
133
+ // Because an early-resolved answer can be processed while an earlier row's
134
+ // handler is still running, the ack never advances past the lowest row whose
135
+ // handler is still in flight (ackable(), below): a crash mid-handler still
136
+ // replays that row.
137
+ //
138
+ // verifyEnvelope/dispatch are unchanged from the poll-based client: every
139
+ // envelope, however it arrived (replay, backfill page, ack response, or a
140
+ // live frame), goes through the SAME signature check and the SAME
141
+ // dispatcher webhook.ts uses.
142
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
143
+ if (k2 === undefined) k2 = k;
144
+ var desc = Object.getOwnPropertyDescriptor(m, k);
145
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
146
+ desc = { enumerable: true, get: function() { return m[k]; } };
147
+ }
148
+ Object.defineProperty(o, k2, desc);
149
+ }) : (function(o, m, k, k2) {
150
+ if (k2 === undefined) k2 = k;
151
+ o[k2] = m[k];
152
+ }));
153
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
154
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
155
+ }) : function(o, v) {
156
+ o["default"] = v;
157
+ });
158
+ var __importStar = (this && this.__importStar) || (function () {
159
+ var ownKeys = function(o) {
160
+ ownKeys = Object.getOwnPropertyNames || function (o) {
161
+ var ar = [];
162
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
163
+ return ar;
164
+ };
165
+ return ownKeys(o);
166
+ };
167
+ return function (mod) {
168
+ if (mod && mod.__esModule) return mod;
169
+ var result = {};
170
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
171
+ __setModuleDefault(result, mod);
172
+ return result;
173
+ };
174
+ })();
175
+ var __importDefault = (this && this.__importDefault) || function (mod) {
176
+ return (mod && mod.__esModule) ? mod : { "default": mod };
177
+ };
178
+ Object.defineProperty(exports, "__esModule", { value: true });
179
+ exports.PING_TIMEOUT_MS = exports.RECONNECT_MAX_DELAY_MS = exports.RECONNECT_MIN_DELAY_MS = exports.DEFAULT_DEDUPE_MAX = void 0;
180
+ exports.MemoryCursorStore = MemoryCursorStore;
181
+ exports.FileCursorStore = FileCursorStore;
182
+ exports.MemoryDedupeStore = MemoryDedupeStore;
183
+ exports.FileDedupeStore = FileDedupeStore;
184
+ exports.createSocketClient = createSocketClient;
185
+ const fsSync = __importStar(require("node:fs"));
186
+ const fs = __importStar(require("node:fs/promises"));
187
+ const node_os_1 = require("node:os");
188
+ const path = __importStar(require("node:path"));
189
+ const ws_1 = __importDefault(require("ws"));
190
+ const webhook_js_1 = require("./webhook.js");
191
+ const ask_js_1 = require("./ask.js");
192
+ const consoleLogger = {
193
+ info: (msg) => console.log(msg),
194
+ error: (msg) => console.error(msg),
195
+ };
196
+ /** Lowercased, filesystem-safe form of an agent id -- used both for
197
+ * defaultStateDir's own directory name and (N9) to key individual files
198
+ * inside a directory that FileCursorStore/FileDedupeStore were given. */
199
+ function safeIdSegment(agentId) {
200
+ return String(agentId).toLowerCase().replace(/[^a-z0-9_-]/g, "_");
201
+ }
202
+ /** `~/.salt/agents/<agentId>` -- the default home for BOTH the cursor and
203
+ * dedupe files for one identity, unless the caller passes its own stores. */
204
+ function defaultStateDir(agentId) {
205
+ return path.join((0, node_os_1.homedir)(), ".salt", "agents", safeIdSegment(agentId));
206
+ }
207
+ // N2/N10 (second security review, 2026-09-18): directories 0700, files
208
+ // 0600 -- these are per-agent secrets-adjacent (a dedupe/cursor file
209
+ // mostly isn't sensitive on its own, but the directory convention is
210
+ // shared with sessions.ts's FileSessionStore, which DOES hold transcript
211
+ // content, so the permission discipline is enforced once, here, for
212
+ // every consumer of writeJsonAtomic rather than trusted to each caller).
213
+ const DIR_MODE = 0o700;
214
+ const FILE_MODE = 0o600;
215
+ async function writeJsonAtomic(target, data) {
216
+ const dir = path.dirname(target);
217
+ // L3 (round 3, 2026-09-18): mkdir's return value is the path of the
218
+ // first directory it actually created, or undefined if the whole path
219
+ // already existed -- only chmod when THIS call created it. A directory
220
+ // that already existed is the caller's (or a previous run's) to manage;
221
+ // forcing 0700 on it would silently tighten permissions on something
222
+ // this SDK doesn't own.
223
+ const created = await fs.mkdir(dir, { recursive: true, mode: DIR_MODE });
224
+ if (created)
225
+ await fs.chmod(dir, DIR_MODE).catch(() => { });
226
+ const tmp = `${target}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
227
+ // The temp file is always freshly created by this call, so it's always
228
+ // ours to chmod.
229
+ await fs.writeFile(tmp, JSON.stringify(data), { mode: FILE_MODE });
230
+ await fs.chmod(tmp, FILE_MODE).catch(() => { });
231
+ await fs.rename(tmp, target);
232
+ }
233
+ // Creates the default state directory (0700, and only chmod'd if THIS
234
+ // call created it -- L3, same reasoning as writeJsonAtomic above) and
235
+ // proves it's actually writable with a probe file. Throws on failure;
236
+ // see resolveDefaultStores below for what the caller does with that
237
+ // (round 2 treated this as fail-closed -- refuse to start at all; round 3
238
+ // relaxes it to a warning + in-memory fallback, since the server-side ack
239
+ // means a lost cursor/dedupe store is no longer a replay risk).
240
+ function ensureStateDirWritable(dir) {
241
+ try {
242
+ const created = fsSync.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
243
+ if (created) {
244
+ try {
245
+ fsSync.chmodSync(dir, DIR_MODE);
246
+ }
247
+ catch {
248
+ // Best-effort; an unwritable directory still fails the probe below.
249
+ }
250
+ }
251
+ const probe = path.join(dir, `.writable-check-${process.pid}-${Math.random().toString(16).slice(2)}`);
252
+ fsSync.writeFileSync(probe, "", { mode: FILE_MODE });
253
+ fsSync.unlinkSync(probe);
254
+ }
255
+ catch (err) {
256
+ throw new Error(`state directory ${dir} could not be created or is not writable: ${err.message}`);
257
+ }
258
+ }
259
+ // Round 3 (2026-09-18): resolves the default file-backed stores for
260
+ // whichever of cursorStore/dedupeStore the caller didn't pass explicitly,
261
+ // falling back to in-memory stores with a clear warning log if the
262
+ // default directory can't be created/written -- rather than round 2's
263
+ // synchronous throw. Safe now because salt-api remembers this agent's
264
+ // last-acked cursor server-side (the Telegram-offset model): losing the
265
+ // local cursor/dedupe set on restart means re-fetching from the SERVER's
266
+ // ack, at worst a small amount of re-verification/re-dedupe work, never a
267
+ // skipped or endlessly-replayed update.
268
+ function resolveDefaultStores(stateDir, agentId, logger) {
269
+ try {
270
+ ensureStateDirWritable(stateDir);
271
+ return { cursorStore: FileCursorStore(stateDir), dedupeStore: FileDedupeStore(stateDir) };
272
+ }
273
+ catch (err) {
274
+ logger.error(`[socket ${agentId}] WARNING: ${err.message} -- falling back to IN-MEMORY cursor/dedupe stores for ` +
275
+ `this run. This is safe: salt-api remembers this agent's last-acked position server-side and a subscribe/poll ` +
276
+ `with no local cursor resumes from there. Pass cursorStore/dedupeStore explicitly to silence this, or fix the ` +
277
+ `directory's permissions to restore persistence across restarts.`);
278
+ return { cursorStore: MemoryCursorStore(), dedupeStore: MemoryDedupeStore() };
279
+ }
280
+ }
281
+ /** In-memory cursor store. Lost on restart (a fresh process re-subscribes
282
+ * from cursor 0, which resumes from salt-api's own server-side ack --
283
+ * see this file's header comment). Opt in explicitly; the default is
284
+ * FileCursorStore -- see createSocketClient. */
285
+ function MemoryCursorStore() {
286
+ const cursors = new Map();
287
+ const key = (id) => String(id).toLowerCase();
288
+ return {
289
+ async get(agentId) {
290
+ return cursors.get(key(agentId)) ?? 0;
291
+ },
292
+ async put(agentId, cursor) {
293
+ cursors.set(key(agentId), cursor);
294
+ },
295
+ };
296
+ }
297
+ /**
298
+ * `<agentId>.cursor.json` inside `dir` -- written atomically (temp file +
299
+ * rename), the same convention sessions.ts's FileSessionStore uses. `dir`
300
+ * defaults to one identity's own directory (see defaultStateDir), but N9
301
+ * (second security review, 2026-09-18) keys the FILENAME by agent id too:
302
+ * a caller pointing several identities at one SHARED directory (rather
303
+ * than createSocketClient's per-identity default) used to collide on the
304
+ * same fixed `cursor.json` -- one agent's cursor silently clobbering
305
+ * another's. Keying by id makes a shared directory safe the same way
306
+ * sessions.ts's one-file-per-key convention already is.
307
+ */
308
+ function FileCursorStore(dir) {
309
+ const fileFor = (agentId) => path.join(dir, `${safeIdSegment(agentId)}.cursor.json`);
310
+ return {
311
+ async get(agentId) {
312
+ try {
313
+ const raw = await fs.readFile(fileFor(agentId), "utf8");
314
+ const parsed = JSON.parse(raw);
315
+ return typeof parsed.cursor === "number" && Number.isFinite(parsed.cursor) ? parsed.cursor : 0;
316
+ }
317
+ catch (err) {
318
+ if (err.code === "ENOENT")
319
+ return 0;
320
+ throw err;
321
+ }
322
+ },
323
+ async put(agentId, cursor) {
324
+ await writeJsonAtomic(fileFor(agentId), { cursor });
325
+ },
326
+ };
327
+ }
328
+ exports.DEFAULT_DEDUPE_MAX = 5000;
329
+ /** In-memory dedupe set, bounded to the last `max` delivery ids (oldest
330
+ * dropped first). Opt in explicitly; the default is FileDedupeStore. */
331
+ function MemoryDedupeStore(max = exports.DEFAULT_DEDUPE_MAX) {
332
+ const seen = new Map();
333
+ const order = new Map();
334
+ const key = (id) => String(id).toLowerCase();
335
+ return {
336
+ async has(agentId, deliveryId) {
337
+ return seen.get(key(agentId))?.has(deliveryId) ?? false;
338
+ },
339
+ async add(agentId, deliveryId) {
340
+ const k = key(agentId);
341
+ const set = seen.get(k) ?? new Set();
342
+ const list = order.get(k) ?? [];
343
+ if (!set.has(deliveryId)) {
344
+ set.add(deliveryId);
345
+ list.push(deliveryId);
346
+ while (list.length > max) {
347
+ const oldest = list.shift();
348
+ if (oldest !== undefined)
349
+ set.delete(oldest);
350
+ }
351
+ }
352
+ seen.set(k, set);
353
+ order.set(k, list);
354
+ },
355
+ };
356
+ }
357
+ /**
358
+ * `<agentId>.seen.json` inside `dir` (an ordered array of the last `max`
359
+ * delivery ids, oldest first) -- same atomic-write convention as
360
+ * FileCursorStore. N9 (second security review, 2026-09-18): the filename
361
+ * is keyed by agent id, same reasoning as FileCursorStore above -- a
362
+ * shared `dir` across identities must not let one identity's dedupe set
363
+ * clobber another's (or worse, let identity B's real deliveries appear
364
+ * "already seen" because they share a delivery_id namespace collision
365
+ * with identity A's file).
366
+ */
367
+ function FileDedupeStore(dir, max = exports.DEFAULT_DEDUPE_MAX) {
368
+ const fileFor = (agentId) => path.join(dir, `${safeIdSegment(agentId)}.seen.json`);
369
+ const cache = new Map(); // mirrors each agent's file within one process so `has` doesn't re-read on every check
370
+ async function load(agentId) {
371
+ const key = safeIdSegment(agentId);
372
+ const cached = cache.get(key);
373
+ if (cached)
374
+ return cached;
375
+ let list;
376
+ try {
377
+ const raw = await fs.readFile(fileFor(agentId), "utf8");
378
+ const parsed = JSON.parse(raw);
379
+ list = Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
380
+ }
381
+ catch (err) {
382
+ if (err.code === "ENOENT")
383
+ list = [];
384
+ else
385
+ throw err;
386
+ }
387
+ cache.set(key, list);
388
+ return list;
389
+ }
390
+ return {
391
+ async has(agentId, deliveryId) {
392
+ const list = await load(agentId);
393
+ return list.includes(deliveryId);
394
+ },
395
+ async add(agentId, deliveryId) {
396
+ const list = await load(agentId);
397
+ if (list.includes(deliveryId))
398
+ return;
399
+ list.push(deliveryId);
400
+ while (list.length > max)
401
+ list.shift();
402
+ cache.set(safeIdSegment(agentId), list);
403
+ await writeJsonAtomic(fileFor(agentId), list);
404
+ },
405
+ };
406
+ }
407
+ /** Exponential reconnect backoff bounds (jittered). Not a poll interval --
408
+ * these only govern the wait between one dropped/closed connection and
409
+ * the next connection attempt. */
410
+ exports.RECONNECT_MIN_DELAY_MS = 1000;
411
+ exports.RECONNECT_MAX_DELAY_MS = 60_000;
412
+ /** No Action Cable ping for this long means the connection is presumed
413
+ * dead (a half-open TCP socket may never emit its own 'close'/'error'). */
414
+ exports.PING_TIMEOUT_MS = 30_000;
415
+ // Honours Retry-After on a 429 -- from Rack::Attack's blanket api-key/ip
416
+ // ceiling on the /cable handshake, or from the backfill/ack HTTP calls'
417
+ // own throttles (N3's agent_updates_longpoll, or the blanket one on any
418
+ // other path). Carries the parsed wait (in ms, if any) alongside the
419
+ // thrown/returned error so the reconnect backoff can use the server's own
420
+ // authoritative figure instead of guessing with exponential backoff, which
421
+ // could easily be far shorter than the throttle window.
422
+ class PollHttpError extends Error {
423
+ retryAfterMs;
424
+ constructor(message, retryAfterMs) {
425
+ super(message);
426
+ this.name = "PollHttpError";
427
+ this.retryAfterMs = retryAfterMs;
428
+ }
429
+ }
430
+ // L4 (round 3, 2026-09-18): a misconfigured or hostile Retry-After must
431
+ // not be able to park this client indefinitely -- clamp to 15 minutes,
432
+ // comfortably above every real throttle window in rack_attack.rb (the
433
+ // longest is the 5-minute blanket one) while still bounding the wait.
434
+ const MAX_RETRY_AFTER_MS = 15 * 60 * 1000;
435
+ /** Rack::Attack (and HTTP generally) sends Retry-After as either a plain
436
+ * integer number of seconds or an HTTP-date; either is honoured, clamped
437
+ * to MAX_RETRY_AFTER_MS. Accepts the header value however the caller's
438
+ * transport hands it back (a single string, or the first of an array --
439
+ * Node's http.IncomingMessage can return either for a repeated header). */
440
+ function parseRetryAfterMs(value) {
441
+ const raw = Array.isArray(value) ? value[0] : value;
442
+ if (!raw)
443
+ return undefined;
444
+ const trimmed = raw.trim();
445
+ if (/^\d+$/.test(trimmed)) {
446
+ const seconds = Number(trimmed);
447
+ return Number.isFinite(seconds) ? Math.min(MAX_RETRY_AFTER_MS, Math.max(0, seconds * 1000)) : undefined;
448
+ }
449
+ const asDate = Date.parse(trimmed);
450
+ return Number.isNaN(asDate) ? undefined : Math.min(MAX_RETRY_AFTER_MS, Math.max(0, asDate - Date.now()));
451
+ }
452
+ function jitter(ms) {
453
+ // Half fixed, half random -- avoids a thundering herd of identical
454
+ // reconnect timings without making the wait unpredictably short.
455
+ return Math.round(ms / 2 + Math.random() * (ms / 2));
456
+ }
457
+ /**
458
+ * Drains one identity's K2 socket-mode outbox by staying connected to
459
+ * salt-api's `AgentUpdatesChannel` over Action Cable, verifying each
460
+ * envelope with the exact same check createWebhookServer applies to a
461
+ * webhook POST (createDispatcher's verifyEnvelope), and dispatching it to
462
+ * the SAME onMessage/onCardInteraction/etc. handlers. See this file's
463
+ * header comment for the full wire protocol, the one-line switch from
464
+ * createWebhookServer, and the reasoning behind cursor persistence, the
465
+ * ack call, backfill, and reconnect.
466
+ */
467
+ function createSocketClient(options) {
468
+ const logger = options.logger ?? consoleLogger;
469
+ const dispatcher = (0, webhook_js_1.createDispatcher)(options);
470
+ const host = options.host.replace(/\/$/, "");
471
+ const wsHost = host.replace(/^http/, "ws"); // http(s):// -> ws(s)://
472
+ const fetchImpl = options.fetchImpl ?? fetch;
473
+ const WS = options.webSocketImpl ?? ws_1.default;
474
+ const stateDir = defaultStateDir(options.agentId);
475
+ // Resolved once, synchronously, right here -- only when at least one
476
+ // default (file-backed) store is actually needed (a caller passing BOTH
477
+ // cursorStore and dedupeStore explicitly has opted out of file
478
+ // persistence entirely, and this directory is never touched on their
479
+ // behalf). Never throws on an unwritable directory -- see
480
+ // resolveDefaultStores.
481
+ const needsStateDir = options.cursorStore === undefined || options.dedupeStore === undefined;
482
+ const defaults = needsStateDir ? resolveDefaultStores(stateDir, options.agentId, logger) : undefined;
483
+ const cursorStore = options.cursorStore ?? defaults.cursorStore;
484
+ const dedupeStore = options.dedupeStore ?? defaults.dedupeStore;
485
+ const limit = options.limit ?? 100;
486
+ const pingTimeoutMs = options.pingTimeoutMs ?? exports.PING_TIMEOUT_MS;
487
+ const minBackoffMs = options.minBackoffMs ?? exports.RECONNECT_MIN_DELAY_MS;
488
+ const maxBackoffMs = options.maxBackoffMs ?? exports.RECONNECT_MAX_DELAY_MS;
489
+ const agentId = options.agentId;
490
+ const apiKey = options.apiKey;
491
+ let stopped = true;
492
+ let loopPromise = null;
493
+ let currentSocket = null;
494
+ let currentAbort = null;
495
+ let wakeSleep = null;
496
+ // The most recent (possibly still in-flight) ack call, so stop() can
497
+ // wait for it -- ack itself is deliberately fire-and-forget relative to
498
+ // frame dispatch (see runAck below), so nothing else awaits this chain.
499
+ let ackTail = Promise.resolve();
500
+ // See this file's header comment ("A ROW THAT FAILS VERIFICATION
501
+ // TRANSIENTLY..."): the lowest update id that has failed verification
502
+ // for a reason that might be transient and hasn't since been resolved.
503
+ // Every cursor persistence point clamps to stop just short of this, so
504
+ // the next reconnect's replay re-delivers it (and everything after --
505
+ // dedupe makes that overlap harmless) instead of losing it for good.
506
+ let pendingFailureId = null;
507
+ function noteTransientFailure(id) {
508
+ if (pendingFailureId === null || id < pendingFailureId)
509
+ pendingFailureId = id;
510
+ }
511
+ function noteResolved(id) {
512
+ if (pendingFailureId !== null && id === pendingFailureId)
513
+ pendingFailureId = null;
514
+ }
515
+ function clampForPersistence(candidate) {
516
+ return pendingFailureId === null ? candidate : Math.min(candidate, pendingFailureId - 1);
517
+ }
518
+ async function persistCursor(candidate) {
519
+ const safe = clampForPersistence(candidate);
520
+ if (safe < 0)
521
+ return; // nothing safe to persist yet (failure sits at/before id 0 -- can't happen in practice, guard anyway)
522
+ try {
523
+ await cursorStore.put(agentId, safe);
524
+ }
525
+ catch (err) {
526
+ logger.error(`[socket ${agentId}] persisting cursor ${safe} failed: ${err.message}`);
527
+ }
528
+ }
529
+ function sleep(ms) {
530
+ return new Promise((resolve) => {
531
+ const timer = setTimeout(() => {
532
+ wakeSleep = null;
533
+ resolve();
534
+ }, ms);
535
+ wakeSleep = () => {
536
+ clearTimeout(timer);
537
+ wakeSleep = null;
538
+ resolve();
539
+ };
540
+ });
541
+ }
542
+ // Verifies + dedupes + dispatches ONE envelope, regardless of whether it
543
+ // arrived as a replay frame, a live frame, or a row fetched via backfill
544
+ // or the ack call -- all four paths funnel through here. Returns
545
+ // "transient" (and records pendingFailureId) for a verification failure
546
+ // that might resolve on retry; every other outcome ("advance") is safe
547
+ // to note as processed, whether that means dispatched, a deduped-skip,
548
+ // or a DEFINITIVE rejection (a genuinely bad signature) -- retrying a
549
+ // definitively bad envelope forever would just wedge this client on it.
550
+ async function handleOne(update) {
551
+ const headers = update.headers || {};
552
+ let result;
553
+ try {
554
+ result = await dispatcher.verifyEnvelope(headers["X-Salt-Agent-Id"], headers["X-Salt-Signature"], update.body);
555
+ }
556
+ catch (err) {
557
+ logger.error(`[socket ${agentId}] verifying update ${update.id} threw: ${err.message}; will retry on the next replay`);
558
+ noteTransientFailure(update.id);
559
+ return "transient";
560
+ }
561
+ if (!result.ok) {
562
+ if (result.transient) {
563
+ logger.error(`[socket ${agentId}] update ${update.id} failed verification transiently (${result.reason}); will retry on the next replay`);
564
+ noteTransientFailure(update.id);
565
+ return "transient";
566
+ }
567
+ logger.error(`[socket ${agentId}] rejected update ${update.id} (${update.event}): ${result.reason}`);
568
+ noteResolved(update.id);
569
+ return "advance";
570
+ }
571
+ noteResolved(update.id);
572
+ const alreadySeen = await dedupeStore.has(agentId, update.delivery_id).catch((err) => {
573
+ logger.error(`[socket ${agentId}] dedupe lookup for ${update.delivery_id} failed: ${err.message}`);
574
+ return false; // fail open on the dedupe check itself -- a lookup failure must not block real delivery
575
+ });
576
+ if (alreadySeen)
577
+ return "advance";
578
+ let body;
579
+ try {
580
+ body = JSON.parse(update.body);
581
+ }
582
+ catch (err) {
583
+ logger.error(`[socket ${agentId}] update ${update.id} had unparseable body: ${err.message}`);
584
+ return "advance";
585
+ }
586
+ // A row whose handler is still running (started on a connection that has
587
+ // since closed) is being handled: replaying it into a second handler is
588
+ // how a stuck ask used to post the same question card again per restart.
589
+ if (handlersInFlight.has(update.id))
590
+ return "advance";
591
+ handlersInFlight.add(update.id);
592
+ try {
593
+ await dispatcher.dispatch(body, headers["X-Salt-Agent-Id"]);
594
+ }
595
+ catch (err) {
596
+ logger.error(`[socket ${agentId}] unhandled error dispatching update ${update.id}: ${err.message}`);
597
+ }
598
+ finally {
599
+ handlersInFlight.delete(update.id);
600
+ }
601
+ await dedupeStore.add(agentId, update.delivery_id).catch((err) => {
602
+ logger.error(`[socket ${agentId}] recording dedupe for ${update.delivery_id} failed: ${err.message}`);
603
+ });
604
+ return "advance";
605
+ }
606
+ // --- Ack (coalesced, event-driven -- see this file's header comment) ---
607
+ let highestProcessed = 0;
608
+ // Rows whose handler is running right now (see FRAME ORDERING above).
609
+ const handlersInFlight = new Set();
610
+ function ackable() {
611
+ let cap = highestProcessed;
612
+ for (const id of handlersInFlight)
613
+ cap = Math.min(cap, id - 1);
614
+ return cap;
615
+ }
616
+ let ackInFlight = false;
617
+ let ackPending = false;
618
+ function noteProcessed(id) {
619
+ if (id > highestProcessed)
620
+ highestProcessed = id;
621
+ }
622
+ function runAck() {
623
+ if (stopped)
624
+ return;
625
+ if (ackable() <= 0)
626
+ return; // nothing processed yet (or only rows still in a handler) -- nothing to ack
627
+ if (ackInFlight) {
628
+ ackPending = true;
629
+ return;
630
+ }
631
+ ackInFlight = true;
632
+ const after = ackable();
633
+ ackTail = (async () => {
634
+ try {
635
+ currentAbort = new AbortController();
636
+ const url = `${host}/api/v1/agent/updates?timeout=0&limit=1&after=${after}`;
637
+ let res;
638
+ try {
639
+ res = await fetchImpl(url, { headers: { "api-key": apiKey }, signal: currentAbort.signal });
640
+ }
641
+ finally {
642
+ currentAbort = null;
643
+ }
644
+ if (!res.ok) {
645
+ logger.error(`[socket ${agentId}] ack request -> ${res.status}`);
646
+ return;
647
+ }
648
+ const parsed = (await res.json());
649
+ for (const row of parsed.updates || []) {
650
+ const outcome = await handleOne(row);
651
+ if (outcome === "advance")
652
+ noteProcessed(row.id);
653
+ }
654
+ const finalCursor = typeof parsed.cursor === "number" ? parsed.cursor : after;
655
+ await persistCursor(finalCursor);
656
+ }
657
+ catch (err) {
658
+ if (!stopped)
659
+ logger.error(`[socket ${agentId}] ack request failed: ${err.message}`);
660
+ }
661
+ finally {
662
+ ackInFlight = false;
663
+ if (ackPending) {
664
+ ackPending = false;
665
+ runAck();
666
+ }
667
+ }
668
+ })();
669
+ }
670
+ // --- Backfill (replay_done.more only -- see this file's header comment) ---
671
+ // Pages forward from `cursor` until a page comes back empty. Persists
672
+ // (clamped) after every page -- each page is a real poll-shaped response,
673
+ // safe to treat exactly like the old poll client's cursor advance.
674
+ // `isClosed` lets the CALLER's connection stop this loop promptly on an
675
+ // ordinary reconnect (not just the global `stopped` flag, which only
676
+ // stop() sets) -- see handleReplayDone's caller for why this runs
677
+ // detached from messageQueue in the first place.
678
+ async function backfillFrom(cursor, isClosed) {
679
+ let after = cursor;
680
+ let backoff = minBackoffMs;
681
+ while (!stopped && !isClosed()) {
682
+ let res;
683
+ try {
684
+ currentAbort = new AbortController();
685
+ const url = `${host}/api/v1/agent/updates?timeout=0&limit=${limit}&after=${after}`;
686
+ try {
687
+ res = await fetchImpl(url, { headers: { "api-key": apiKey }, signal: currentAbort.signal });
688
+ }
689
+ finally {
690
+ currentAbort = null;
691
+ }
692
+ }
693
+ catch (err) {
694
+ if (stopped || isClosed())
695
+ return;
696
+ logger.error(`[socket ${agentId}] backfill request failed: ${err.message}; retrying in ${backoff}ms`);
697
+ await sleep(backoff);
698
+ backoff = Math.min(backoff * 2, maxBackoffMs);
699
+ continue;
700
+ }
701
+ if (!res.ok) {
702
+ if (stopped || isClosed())
703
+ return;
704
+ logger.error(`[socket ${agentId}] backfill -> ${res.status}; retrying in ${backoff}ms`);
705
+ await sleep(backoff);
706
+ backoff = Math.min(backoff * 2, maxBackoffMs);
707
+ continue;
708
+ }
709
+ backoff = minBackoffMs;
710
+ const parsed = (await res.json());
711
+ const rows = parsed.updates || [];
712
+ for (const row of rows) {
713
+ const outcome = await handleOne(row);
714
+ if (outcome === "advance") {
715
+ noteProcessed(row.id);
716
+ after = row.id;
717
+ }
718
+ }
719
+ const finalCursor = typeof parsed.cursor === "number" ? parsed.cursor : after;
720
+ after = finalCursor;
721
+ await persistCursor(after);
722
+ if (rows.length === 0)
723
+ return; // caught up
724
+ }
725
+ }
726
+ // --- One websocket connection's lifecycle -----------------------------
727
+ // Resolves once this connection has closed (however it closed), with an
728
+ // optional retryAfterMs to override the next reconnect wait, and whether
729
+ // it ever reached confirm_subscription (a clean connection resets the
730
+ // exponential backoff).
731
+ function runConnection() {
732
+ return (async () => {
733
+ let localCursor = 0;
734
+ try {
735
+ localCursor = await cursorStore.get(agentId);
736
+ }
737
+ catch (err) {
738
+ logger.error(`[socket ${agentId}] loading cursor failed, starting from 0: ${err.message}`);
739
+ }
740
+ return new Promise((resolveConn) => {
741
+ let settled = false;
742
+ let subscribed = false;
743
+ let retryAfterMsOverride;
744
+ let state = "replaying";
745
+ let liveBuffer = [];
746
+ let pingTimer = null;
747
+ let closed = false; // this CONNECTION closed (distinct from `stopped`, the whole client) -- stops backfillTail promptly on an ordinary reconnect
748
+ let backfillTail = Promise.resolve();
749
+ // Frames must be handled strictly in arrival order, but handleOne
750
+ // is async -- Node's ws library can emit several buffered
751
+ // 'message' events synchronously before the first handler's await
752
+ // resolves, so without this chain two frames could be verified/
753
+ // dispatched out of order or concurrently. Each 'message' handler
754
+ // only ever appends to this chain, never awaits work directly.
755
+ let messageQueue = Promise.resolve();
756
+ function clearPingTimer() {
757
+ if (pingTimer) {
758
+ clearTimeout(pingTimer);
759
+ pingTimer = null;
760
+ }
761
+ }
762
+ function armPingWatchdog() {
763
+ clearPingTimer();
764
+ pingTimer = setTimeout(() => {
765
+ logger.error(`[socket ${agentId}] no ping for ${pingTimeoutMs}ms; treating the connection as dead`);
766
+ try {
767
+ socket.terminate();
768
+ }
769
+ catch {
770
+ // already gone
771
+ }
772
+ }, pingTimeoutMs);
773
+ }
774
+ function finish() {
775
+ if (settled)
776
+ return;
777
+ settled = true;
778
+ closed = true;
779
+ clearPingTimer();
780
+ currentSocket = null;
781
+ // Wait for this connection's own backfillTail (if any) to notice
782
+ // `closed` and unwind before resolving -- otherwise loop() could
783
+ // start a NEW connection while this one's backfill is still
784
+ // mid-flight, and stop()'s "resolves once everything has settled"
785
+ // guarantee would be a lie.
786
+ backfillTail.finally(() => {
787
+ resolveConn({ retryAfterMs: retryAfterMsOverride, subscribed });
788
+ });
789
+ }
790
+ const socket = new WS(`${wsHost}/cable`, { headers: { "api-key": apiKey } });
791
+ currentSocket = socket;
792
+ async function handleReplayDone(frame) {
793
+ const serverCursor = typeof frame.cursor === "number" ? frame.cursor : localCursor;
794
+ await persistCursor(serverCursor);
795
+ if (frame.more) {
796
+ state = "backfilling";
797
+ // Deliberately NOT awaited here. This handler is one link in
798
+ // messageQueue -- awaiting the whole backfill (which can take
799
+ // several HTTP round trips) inside it would block every
800
+ // SUBSEQUENT frame's handler from running at all until backfill
801
+ // finished, since messageQueue only runs one link at a time.
802
+ // That would make the `state === "backfilling"` buffering
803
+ // branch below unreachable: a live frame could never actually
804
+ // get buffered if its own handler never got to run in the
805
+ // first place. Running it detached lets messageQueue keep
806
+ // draining (buffering live frames as they arrive) while this
807
+ // resolves independently; backfillTail is what finish() waits
808
+ // on so stop()/reconnect still settle cleanly.
809
+ backfillTail = backfillFrom(serverCursor, () => closed)
810
+ .catch((err) => {
811
+ logger.error(`[socket ${agentId}] backfill failed: ${err.message}`);
812
+ })
813
+ .then(() => {
814
+ // The drain itself is routed through messageQueue too --
815
+ // not just backfillFrom above -- so it can never race a
816
+ // live frame that arrives (and gets appended to the
817
+ // queue) in the exact window backfill just finished. Without
818
+ // this, a frame queued between "snapshot the buffer" and
819
+ // "flip state to live" would land in a liveBuffer nobody
820
+ // ever drains again -- serializing it here means a frame
821
+ // still sees state === "backfilling" (and buffers itself,
822
+ // picked up by the NEXT drain -- though there is only ever
823
+ // one) or runs after state flips to "live" (and takes the
824
+ // ordinary live-dispatch path) -- never the gap in between.
825
+ const drained = messageQueue.then(async () => {
826
+ const buffered = liveBuffer.slice().sort((a, b) => a.id - b.id);
827
+ liveBuffer = [];
828
+ for (const row of buffered) {
829
+ const outcome = await handleOne(row);
830
+ if (outcome === "advance")
831
+ noteProcessed(row.id);
832
+ }
833
+ state = "live";
834
+ runAck();
835
+ });
836
+ messageQueue = drained.catch((err) => {
837
+ logger.error(`[socket ${agentId}] error draining the backfill live-frame buffer: ${err.message}`);
838
+ });
839
+ return messageQueue;
840
+ });
841
+ return;
842
+ }
843
+ state = "live";
844
+ runAck();
845
+ }
846
+ // Control frames: handled at arrival, never queued (rule (a) in the
847
+ // FRAME ORDERING comment). Returns true when the frame was one.
848
+ function handleControl(frame) {
849
+ const type = frame.type;
850
+ if (type === "ping" || type === "welcome") {
851
+ armPingWatchdog();
852
+ return true;
853
+ }
854
+ if (type === "confirm_subscription") {
855
+ subscribed = true;
856
+ logger.info(`[socket ${agentId}] subscribed (cursor ${localCursor})`);
857
+ return true;
858
+ }
859
+ if (type === "reject_subscription") {
860
+ logger.error(`[socket ${agentId}] subscription rejected; reconnecting`);
861
+ try {
862
+ socket.close();
863
+ }
864
+ catch {
865
+ // already gone
866
+ }
867
+ return true;
868
+ }
869
+ if (type === "disconnect") {
870
+ // Server-initiated close (e.g. a deploy) -- the 'close' event
871
+ // follows and drives reconnect the same as any other close.
872
+ logger.info(`[socket ${agentId}] server requested disconnect${frame.reason ? ` (${frame.reason})` : ""}`);
873
+ return true;
874
+ }
875
+ return false;
876
+ }
877
+ // Rule (b): settles a pending ctx.ask from an envelope that answers
878
+ // it, ahead of the handler queue. False for anything else (including
879
+ // a bad signature or a repeat delivery -- the queued path logs and
880
+ // skips those exactly as before).
881
+ async function tryEarlyAnswer(update) {
882
+ if (!(0, ask_js_1.hasPendingAsks)())
883
+ return false;
884
+ // The row whose handler is asking must never answer itself when a
885
+ // reconnect replays it.
886
+ if (handlersInFlight.has(update.id))
887
+ return false;
888
+ const headers = update.headers || {};
889
+ try {
890
+ const verified = await dispatcher.verifyEnvelope(headers["X-Salt-Agent-Id"], headers["X-Salt-Signature"], update.body);
891
+ if (!verified.ok)
892
+ return false;
893
+ if (await dedupeStore.has(agentId, update.delivery_id))
894
+ return false;
895
+ const body = JSON.parse(update.body);
896
+ if (!(await dispatcher.resolveAnswer(body, headers["X-Salt-Agent-Id"])))
897
+ return false;
898
+ }
899
+ catch (err) {
900
+ logger.error(`[socket ${agentId}] early answer check for update ${update.id} failed: ${err.message}; queueing it normally`);
901
+ return false;
902
+ }
903
+ noteResolved(update.id);
904
+ await dedupeStore.add(agentId, update.delivery_id).catch((err) => {
905
+ logger.error(`[socket ${agentId}] recording dedupe for ${update.delivery_id} failed: ${err.message}`);
906
+ });
907
+ noteProcessed(update.id);
908
+ if (state === "live")
909
+ runAck();
910
+ return true;
911
+ }
912
+ async function handleFrame(frame) {
913
+ const payload = frame.message;
914
+ if (!payload || typeof payload !== "object")
915
+ return;
916
+ if (payload.type === "replay_done") {
917
+ await handleReplayDone(payload);
918
+ return;
919
+ }
920
+ if (typeof payload.id !== "number")
921
+ return;
922
+ const update = payload;
923
+ if (state === "backfilling") {
924
+ liveBuffer.push(update);
925
+ return;
926
+ }
927
+ const outcome = await handleOne(update);
928
+ if (outcome === "advance") {
929
+ noteProcessed(update.id);
930
+ if (state === "live")
931
+ runAck();
932
+ }
933
+ }
934
+ socket.on("open", () => {
935
+ const identifier = localCursor > 0
936
+ ? JSON.stringify({ channel: "AgentUpdatesChannel", after: localCursor })
937
+ : JSON.stringify({ channel: "AgentUpdatesChannel" });
938
+ socket.send(JSON.stringify({ command: "subscribe", identifier }));
939
+ });
940
+ // Arrival chain: verify + maybe decrypt an answer, never a handler, so
941
+ // it cannot deadlock; it only exists to keep frames entering
942
+ // messageQueue in arrival order while an early check is in flight.
943
+ let arrivalChain = Promise.resolve();
944
+ socket.on("message", (raw) => {
945
+ let frame;
946
+ try {
947
+ frame = JSON.parse(raw.toString());
948
+ }
949
+ catch (err) {
950
+ logger.error(`[socket ${agentId}] unparseable frame: ${err.message}`);
951
+ return;
952
+ }
953
+ if (handleControl(frame))
954
+ return;
955
+ arrivalChain = arrivalChain
956
+ .then(async () => {
957
+ const payload = frame.message;
958
+ if (payload && typeof payload === "object" && payload.type !== "replay_done" && typeof payload.id === "number") {
959
+ if (await tryEarlyAnswer(payload))
960
+ return;
961
+ }
962
+ messageQueue = messageQueue.then(() => handleFrame(frame)).catch((err) => {
963
+ logger.error(`[socket ${agentId}] error handling frame: ${err.message}`);
964
+ });
965
+ })
966
+ .catch((err) => {
967
+ logger.error(`[socket ${agentId}] error handling frame: ${err.message}`);
968
+ });
969
+ });
970
+ socket.on("unexpected-response", (_req, res) => {
971
+ const status = res.statusCode;
972
+ if (status === 429) {
973
+ retryAfterMsOverride = parseRetryAfterMs(res.headers["retry-after"]);
974
+ }
975
+ logger.error(`[socket ${agentId}] handshake failed: HTTP ${status}`);
976
+ res.resume(); // drain so the underlying socket can close cleanly
977
+ finish(); // never wait on messageQueue: a handler parked in ctx.ask would hold the reconnect hostage
978
+ });
979
+ socket.on("close", () => {
980
+ finish(); // never wait on messageQueue: a handler parked in ctx.ask would hold the reconnect hostage
981
+ });
982
+ socket.on("error", (err) => {
983
+ logger.error(`[socket ${agentId}] websocket error: ${err.message}`);
984
+ // 'close' normally follows an 'error' in the ws library; finish()
985
+ // runs there. If it somehow doesn't, stop()'s terminate() (or a
986
+ // future reconnect attempt) still recovers the process.
987
+ });
988
+ });
989
+ })();
990
+ }
991
+ async function loop() {
992
+ let backoff = minBackoffMs;
993
+ logger.info(`[socket ${agentId}] connecting to ${wsHost}/cable`);
994
+ while (!stopped) {
995
+ let outcome;
996
+ try {
997
+ outcome = await runConnection();
998
+ }
999
+ catch (err) {
1000
+ logger.error(`[socket ${agentId}] connection failed: ${err.message}`);
1001
+ outcome = { subscribed: false };
1002
+ }
1003
+ if (stopped)
1004
+ break;
1005
+ if (outcome.subscribed)
1006
+ backoff = minBackoffMs; // a clean connection resets the failure backoff
1007
+ const waitMs = outcome.retryAfterMs ?? jitter(backoff);
1008
+ logger.error(`[socket ${agentId}] reconnecting in ${waitMs}ms` + (outcome.retryAfterMs !== undefined ? " (Retry-After)" : ""));
1009
+ await sleep(waitMs);
1010
+ backoff = Math.min(backoff * 2, maxBackoffMs);
1011
+ }
1012
+ }
1013
+ return {
1014
+ start() {
1015
+ if (loopPromise)
1016
+ return; // already running
1017
+ stopped = false;
1018
+ loopPromise = loop().catch((err) => logger.error(`[socket ${agentId}] loop exited unexpectedly: ${err.message}`));
1019
+ },
1020
+ async stop() {
1021
+ stopped = true;
1022
+ wakeSleep?.();
1023
+ currentAbort?.abort();
1024
+ if (currentSocket) {
1025
+ try {
1026
+ currentSocket.terminate();
1027
+ }
1028
+ catch {
1029
+ // already gone
1030
+ }
1031
+ }
1032
+ await loopPromise;
1033
+ loopPromise = null;
1034
+ await ackTail.catch(() => { });
1035
+ },
1036
+ };
1037
+ }
1038
+ //# sourceMappingURL=socket.js.map