salt-agent-sdk 0.1.0 → 0.18.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/LICENSE +21 -0
- package/README.md +941 -30
- package/dist/actions.d.ts +21 -5
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +673 -54
- package/dist/actions.js.map +1 -1
- package/dist/ask.d.ts +83 -0
- package/dist/ask.d.ts.map +1 -0
- package/dist/ask.js +215 -0
- package/dist/ask.js.map +1 -0
- package/dist/chatPrivacy.d.ts +34 -0
- package/dist/chatPrivacy.d.ts.map +1 -0
- package/dist/chatPrivacy.js +60 -0
- package/dist/chatPrivacy.js.map +1 -0
- package/dist/client.d.ts +2783 -35
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1200 -143
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +16 -6
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -5
- package/dist/config.js.map +1 -1
- package/dist/crypto.d.ts +62 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +121 -0
- package/dist/crypto.js.map +1 -1
- package/dist/delegations.d.ts +25 -8
- package/dist/delegations.d.ts.map +1 -1
- package/dist/delegations.js +67 -11
- package/dist/delegations.js.map +1 -1
- package/dist/device/client.d.ts +161 -0
- package/dist/device/client.d.ts.map +1 -0
- package/dist/device/client.js +601 -0
- package/dist/device/client.js.map +1 -0
- package/dist/device/host.d.ts +222 -0
- package/dist/device/host.d.ts.map +1 -0
- package/dist/device/host.js +891 -0
- package/dist/device/host.js.map +1 -0
- package/dist/device/http.d.ts +12 -0
- package/dist/device/http.d.ts.map +1 -0
- package/dist/device/http.js +124 -0
- package/dist/device/http.js.map +1 -0
- package/dist/device/index.d.ts +5 -0
- package/dist/device/index.d.ts.map +1 -0
- package/dist/device/index.js +27 -0
- package/dist/device/index.js.map +1 -0
- package/dist/device/protocol.d.ts +312 -0
- package/dist/device/protocol.d.ts.map +1 -0
- package/dist/device/protocol.js +435 -0
- package/dist/device/protocol.js.map +1 -0
- package/dist/identities.d.ts +9 -2
- package/dist/identities.d.ts.map +1 -1
- package/dist/identities.js +33 -3
- package/dist/identities.js.map +1 -1
- package/dist/identity.d.ts +155 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +196 -0
- package/dist/identity.js.map +1 -0
- package/dist/identityShare.d.ts +129 -0
- package/dist/identityShare.d.ts.map +1 -0
- package/dist/identityShare.js +308 -0
- package/dist/identityShare.js.map +1 -0
- package/dist/ids.d.ts +11 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +17 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/reconcile.d.ts +25 -0
- package/dist/reconcile.d.ts.map +1 -0
- package/dist/reconcile.js +60 -0
- package/dist/reconcile.js.map +1 -0
- package/dist/recovery.d.ts +51 -0
- package/dist/recovery.d.ts.map +1 -0
- package/dist/recovery.js +59 -0
- package/dist/recovery.js.map +1 -0
- package/dist/register.d.ts +32 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +65 -0
- package/dist/register.js.map +1 -0
- package/dist/scriptJob.d.ts +92 -0
- package/dist/scriptJob.d.ts.map +1 -0
- package/dist/scriptJob.js +270 -0
- package/dist/scriptJob.js.map +1 -0
- package/dist/sessions.d.ts +131 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +240 -0
- package/dist/sessions.js.map +1 -0
- package/dist/socket.d.ts +115 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/socket.js +1038 -0
- package/dist/socket.js.map +1 -0
- package/dist/webhook.d.ts +613 -24
- package/dist/webhook.d.ts.map +1 -1
- package/dist/webhook.js +1384 -83
- package/dist/webhook.js.map +1 -1
- package/dist/work.d.ts +57 -0
- package/dist/work.d.ts.map +1 -0
- package/dist/work.js +213 -0
- package/dist/work.js.map +1 -0
- 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
|