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
@@ -0,0 +1,891 @@
1
+ "use strict";
2
+ // The DEVICE side of device control: createDeviceHost.
3
+ //
4
+ // It runs ON the controlled machine, inside the signed desktop app. It takes a
5
+ // decrypted command off the lane, decides whether it is allowed, and -- only
6
+ // if it is -- calls the matching HANDLER. It then encrypts the result for the
7
+ // lane readers and posts it, beats while the session is live, and reports
8
+ // per-class counts (never content) to the server.
9
+ //
10
+ // ============================================================================
11
+ // THE SEAM BOUNDARY (deliberately NOT implemented here).
12
+ // ============================================================================
13
+ // This module performs NO observation and NO input of any kind. It calls the
14
+ // `DeviceHostHandlers` the desktop app injects:
15
+ // - observe() -> a UI observation
16
+ // - click/type/key/scroll() -> acting on the UI
17
+ // - focusApp/listApps/readFile/writeFile() -> app + filesystem access
18
+ // The host decides IF a handler may run (capability, scope, approval mode,
19
+ // never-allowed floor, budget, rate limit, ordering) and what the device tells
20
+ // the server afterwards (per-class counts only, never content). The handler
21
+ // decides HOW.
22
+ //
23
+ // On the device side (salt-device) each handler is an ORCHESTRATOR over two OS
24
+ // primitives, a preferred one and a sensitive fallback, kept in separate files
25
+ // so either can be replaced on its own:
26
+ // - observe -> a structured UI snapshot (accessibility tree) [preferred]
27
+ // falling back to a raw framebuffer grab [sensitive]
28
+ // - act -> semantic accessibility actions (press/setValue) [preferred]
29
+ // falling back to raw input-event synthesis [sensitive]
30
+ // The two sensitive primitives (framebuffer grab, event synthesis) are the only
31
+ // pieces a safety review scrutinises, and the split keeps them small, isolated
32
+ // and swappable while the authority model here stays reviewable on its own.
33
+ // ============================================================================
34
+ var __importDefault = (this && this.__importDefault) || function (mod) {
35
+ return (mod && mod.__esModule) ? mod : { "default": mod };
36
+ };
37
+ Object.defineProperty(exports, "__esModule", { value: true });
38
+ exports.NEVER_ALLOWED_APPS = exports.sharedDeviceLock = exports.DeviceLock = exports.DeviceBeatOverError = exports.DeviceBudgetSpentError = void 0;
39
+ exports.ackBody = ackBody;
40
+ exports.createDeviceHost = createDeviceHost;
41
+ const protocol_js_1 = require("./protocol.js");
42
+ const node_path_1 = __importDefault(require("node:path"));
43
+ const ids_js_1 = require("../ids.js");
44
+ const SPEND_COUNT = {
45
+ command: "command",
46
+ look: "look",
47
+ act: "click",
48
+ observe: "observe",
49
+ apps: "app_focus",
50
+ };
51
+ /** `ctx.spend` when the grant's `max_actions` is already spent. A `DeviceProtocolError` with
52
+ * code `out_of_scope`, so a handler that lets it bubble answers the agent "action budget
53
+ * spent" and the host ends the session `budget_exhausted`. */
54
+ class DeviceBudgetSpentError extends protocol_js_1.DeviceProtocolError {
55
+ constructor() {
56
+ super("out_of_scope", "action budget spent");
57
+ this.name = "DeviceBudgetSpentError";
58
+ }
59
+ }
60
+ exports.DeviceBudgetSpentError = DeviceBudgetSpentError;
61
+ const ACK_NOTE_MAX = 300;
62
+ /** The ack body: the allow-listed handler fields (typed: finite number, method: string,
63
+ * verified: boolean, note: string capped at 300), then `ok: true` (which a handler cannot
64
+ * override, so an old agent that only reads `ok` sees the same wire it always did). */
65
+ function ackBody(result) {
66
+ const out = {};
67
+ if (result && typeof result === "object" && !Array.isArray(result)) {
68
+ const r = result;
69
+ if (typeof r.typed === "number" && Number.isFinite(r.typed))
70
+ out.typed = r.typed;
71
+ if (typeof r.method === "string")
72
+ out.method = r.method.length > ACK_NOTE_MAX ? r.method.slice(0, ACK_NOTE_MAX) : r.method;
73
+ if (typeof r.verified === "boolean")
74
+ out.verified = r.verified;
75
+ if (typeof r.note === "string")
76
+ out.note = r.note.length > ACK_NOTE_MAX ? r.note.slice(0, ACK_NOTE_MAX) : r.note;
77
+ }
78
+ out.ok = true;
79
+ return out;
80
+ }
81
+ class DeviceBeatOverError extends Error {
82
+ }
83
+ exports.DeviceBeatOverError = DeviceBeatOverError;
84
+ /** A FIFO async mutex. One of these guards every mutating command (click /
85
+ * type / key / scroll / focus_app) on the machine, whichever session sent it.
86
+ * `sharedDeviceLock` is the process-wide instance every host uses by default,
87
+ * so two sessions on one machine can never interleave input. salt-device may
88
+ * pass its own via `options.deviceLock` (e.g. to also hold it around local
89
+ * tray actions that inject input). */
90
+ class DeviceLock {
91
+ tail = Promise.resolve();
92
+ /** Resolves with a release function once every earlier holder released. */
93
+ acquire() {
94
+ let release;
95
+ const gate = new Promise((r) => (release = r));
96
+ const ready = this.tail.then(() => release_once(release));
97
+ this.tail = this.tail.then(() => gate);
98
+ return ready;
99
+ }
100
+ /** Run `fn` while holding the lock. */
101
+ async run(fn) {
102
+ const release = await this.acquire();
103
+ try {
104
+ return await fn();
105
+ }
106
+ finally {
107
+ release();
108
+ }
109
+ }
110
+ }
111
+ exports.DeviceLock = DeviceLock;
112
+ function release_once(release) {
113
+ let done = false;
114
+ return () => {
115
+ if (done)
116
+ return;
117
+ done = true;
118
+ release();
119
+ };
120
+ }
121
+ /** The process-wide device mutex (default for every host). */
122
+ exports.sharedDeviceLock = new DeviceLock();
123
+ /** Apps the floor never allows a command to target, whatever a mandate says:
124
+ * password managers and keychains. The desktop app extends this with its own
125
+ * Salt windows (which it also blanks in the capture handler). Browsers are
126
+ * deliberately NOT here (owner: browsers full, no deny-list). */
127
+ exports.NEVER_ALLOWED_APPS = [
128
+ "com.1password.1password",
129
+ "com.agilebits.onepassword7",
130
+ "com.apple.keychainaccess",
131
+ "com.apple.passwords",
132
+ "com.bitwarden.desktop",
133
+ "com.dashlane.dashlane",
134
+ "org.keepassxc.keepassxc",
135
+ "1password",
136
+ "bitwarden",
137
+ "keepassxc",
138
+ "keychain access",
139
+ "passwords",
140
+ "dashlane",
141
+ ];
142
+ function createDeviceHost(snapshot, handlers, transport, callbacks = {}, options = {}) {
143
+ const deviceLock = options.deviceLock ?? exports.sharedDeviceLock;
144
+ const clock = options.now ?? Date.now;
145
+ const beatIntervalMs = options.beatIntervalMs ?? 5_000;
146
+ const progressMinIntervalMs = options.progressMinIntervalMs ?? protocol_js_1.PROGRESS_MIN_INTERVAL_MS;
147
+ const taskGraceMs = options.taskGraceMs ?? protocol_js_1.TASK_DEVICE_GRACE_MS;
148
+ const neverAllowed = (options.neverAllowedApps ?? exports.NEVER_ALLOWED_APPS).map((s) => s.toLowerCase());
149
+ let lastSeq = 0;
150
+ // Newest command this host actually executed (for lease reports).
151
+ let lastRanAt = null;
152
+ let lastRanSeq = null;
153
+ let pausedReason = null;
154
+ // One command at a time per session: every command runs behind this tail.
155
+ let tail = Promise.resolve();
156
+ // Ids received and not yet started / ids a `cancel` asked us to drop.
157
+ const queuedIds = new Map();
158
+ // Ids a `cancel` named (even before the command arrived). Bounded: oldest dropped.
159
+ const cancelledIds = new Set();
160
+ const CANCELLED_CAP = 256;
161
+ // What Salt has been told about our pause state (null = running).
162
+ let reportedPause = null;
163
+ let pauseSync = Promise.resolve();
164
+ // A device whose clock runs ahead of the sender's may see an `exp` this far in the future.
165
+ const EXP_SKEW_MS = 5_000;
166
+ // The last RESULT_CACHE_SIZE results by command id (insertion order = age).
167
+ const resultCache = new Map();
168
+ let spent = 0; // budgeted actions used
169
+ let ended = false;
170
+ const startedAt = Date.now();
171
+ let beatTimer = null;
172
+ let durationTimer = null;
173
+ // Rate windows: timestamps of recent commands / observes.
174
+ const cmdTimes = [];
175
+ const obsTimes = [];
176
+ // Pending counts to flush to the server, coalesced.
177
+ const pendingCounts = {};
178
+ let countsFlushQueued = false;
179
+ // Tasks being run right now, by command id: `cancel` and the end of the session abort them.
180
+ const runningTasks = new Map();
181
+ // Callers of ctx.waitWhilePaused(), released by resume().
182
+ const resumeWaiters = new Set();
183
+ function audit(entry) {
184
+ callbacks.onAudit?.({ at: Date.now(), ...entry });
185
+ }
186
+ function bump(cls) {
187
+ pendingCounts[cls] = (pendingCounts[cls] ?? 0) + 1;
188
+ if (countsFlushQueued || ended)
189
+ return;
190
+ countsFlushQueued = true;
191
+ // Flush on the next tick so a burst of commands coalesces into one PATCH.
192
+ queueMicrotask(async () => {
193
+ countsFlushQueued = false;
194
+ const batch = { ...pendingCounts };
195
+ for (const k of Object.keys(pendingCounts))
196
+ delete pendingCounts[k];
197
+ if (Object.keys(batch).length === 0)
198
+ return;
199
+ try {
200
+ // last_seq only rides with real work: a batch of nothing but handler
201
+ // failures must not look like progress (Salt extends the lease on it).
202
+ const work = Object.keys(batch).some((k) => k !== "error");
203
+ await transport.reportCounts(snapshot.sessionId, batch, work && lastRanSeq !== null ? { last_seq: lastRanSeq } : undefined);
204
+ }
205
+ catch {
206
+ /* counts are metadata; a failed PATCH never stops control */
207
+ }
208
+ });
209
+ }
210
+ function withinRate(op, now) {
211
+ while (cmdTimes.length && now - cmdTimes[0] >= 1000)
212
+ cmdTimes.shift();
213
+ while (obsTimes.length && now - obsTimes[0] >= 1000)
214
+ obsTimes.shift();
215
+ if (cmdTimes.length >= protocol_js_1.MAX_COMMANDS_PER_SECOND)
216
+ return false;
217
+ if (op === "observe" && obsTimes.length >= protocol_js_1.MAX_OBSERVE_PER_SECOND)
218
+ return false;
219
+ cmdTimes.push(now);
220
+ if (op === "observe")
221
+ obsTimes.push(now);
222
+ return true;
223
+ }
224
+ async function reply(header, op, body) {
225
+ const id = (0, protocol_js_1.deviceMessageId)();
226
+ const plaintext = (0, protocol_js_1.encodeDeviceMessage)({ v: protocol_js_1.DEVICE_PROTOCOL_VERSION, id, seq: nextOutSeq(), op, session: snapshot.sessionId, re: header.id }, body);
227
+ if (Buffer.byteLength(plaintext, "utf8") > protocol_js_1.MAX_DEVICE_PLAINTEXT_BYTES) {
228
+ // The result itself is too big (a huge screenshot or file). Answer the
229
+ // small error instead; never post an over-cap message.
230
+ const small = (0, protocol_js_1.encodeDeviceMessage)({ v: protocol_js_1.DEVICE_PROTOCOL_VERSION, id: (0, protocol_js_1.deviceMessageId)(), seq: nextOutSeq(), op: "error", session: snapshot.sessionId, re: header.id }, { code: "too_large", message: "result exceeds the per-message limit" });
231
+ await transport.postResult(small, snapshot.readers);
232
+ return { op: "error", body: { code: "too_large", message: "result exceeds the per-message limit" } };
233
+ }
234
+ await transport.postResult(plaintext, snapshot.readers);
235
+ return { op, body };
236
+ }
237
+ function remember(id, r) {
238
+ resultCache.delete(id);
239
+ resultCache.set(id, r);
240
+ while (resultCache.size > protocol_js_1.RESULT_CACHE_SIZE) {
241
+ resultCache.delete(resultCache.keys().next().value);
242
+ }
243
+ }
244
+ let outSeq = 0;
245
+ function nextOutSeq() {
246
+ outSeq += 1;
247
+ return outSeq;
248
+ }
249
+ async function refuse(header, code, message, extra = {}) {
250
+ audit({ commandId: header.id, op: header.op, decision: code === "needs_approval" ? "needs_approval" : "refused", code, reason: message });
251
+ // Refusals are not actions and are not reported as counts (they would
252
+ // otherwise extend the lease and spend the budget server-side).
253
+ return reply(header, "error", { code, message, ...extra });
254
+ }
255
+ /** Why a path is refused, or null when it is inside a root. Only absolute,
256
+ * already-normalised paths are accepted (`..`, `.`, doubled or trailing
257
+ * slashes are refused, never resolved), compared to the resolved roots on
258
+ * segment boundaries. The device re-checks against the real filesystem
259
+ * (symlinks) at I/O time. */
260
+ function fileScopeProblem(rule, p) {
261
+ if (!rule)
262
+ return "no files capability";
263
+ if (!rule.fileRoots || rule.fileRoots.length === 0)
264
+ return "files need an explicit root";
265
+ if (typeof p !== "string" || p.length === 0 || p.includes("\0"))
266
+ return "path must be a non-empty string";
267
+ if (!node_path_1.default.isAbsolute(p) || node_path_1.default.resolve(p) !== p)
268
+ return "path must be absolute and normalised";
269
+ const inside = rule.fileRoots.some((root) => {
270
+ const r = node_path_1.default.resolve(root);
271
+ return p === r || p.startsWith(r.endsWith(node_path_1.default.sep) ? r : r + node_path_1.default.sep);
272
+ });
273
+ return inside ? null : "path is outside the allowed roots";
274
+ }
275
+ /** The floor: an app named by id OR by a longer name that contains a never-allowed one
276
+ * ("1Password 7 - Password Manager") is refused whatever the mandate says. */
277
+ function isNeverAllowed(app) {
278
+ const a = app.toLowerCase();
279
+ return neverAllowed.some((n) => a === n || a.includes(n));
280
+ }
281
+ function appInScope(rule, app) {
282
+ if (isNeverAllowed(app))
283
+ return false;
284
+ if (!rule)
285
+ return false;
286
+ if (!rule.apps || rule.apps.length === 0)
287
+ return true; // any app the floor allows
288
+ return rule.apps.some((a) => a.toLowerCase() === app.toLowerCase());
289
+ }
290
+ /** Entry point. Sync part: decode, drop what is not ours, handle `cancel`
291
+ * immediately. Everything else queues behind the session's tail so commands
292
+ * run strictly one at a time, in arrival order. */
293
+ async function handleCommand(plaintext) {
294
+ if (ended)
295
+ return false;
296
+ let decoded;
297
+ try {
298
+ decoded = (0, protocol_js_1.decodeDeviceMessage)(plaintext);
299
+ }
300
+ catch (e) {
301
+ // A line that looked like a device message but is malformed: we cannot
302
+ // trust its id to answer, so drop it. (A well-formed bad_args is answered
303
+ // below, once we have a header to reply to.)
304
+ void e;
305
+ return false;
306
+ }
307
+ if (!decoded)
308
+ return false;
309
+ const { header, body } = decoded;
310
+ // Only our active session; anything else is dropped silently.
311
+ if (!(0, ids_js_1.sameId)(header.session, snapshot.sessionId))
312
+ return false;
313
+ if (header.op === "cancel" && header.v === protocol_js_1.DEVICE_PROTOCOL_VERSION) {
314
+ // Out of band: never queued, consumes no seq. Drops a command that has
315
+ // not started; a started or finished one keeps its stored result.
316
+ // Remembered even when the command has not arrived yet (messages can
317
+ // reorder); processed on arrival, so a long action never delays it.
318
+ // The target is body `{id}` (a command) or the header's `re` (a task); a running task is
319
+ // aborted, which ends it `stopped`.
320
+ const bodyId = body?.id;
321
+ const id = typeof bodyId === "string" ? bodyId : header.re;
322
+ const running = typeof id === "string" ? runningTasks.get(id) : undefined;
323
+ if (running)
324
+ running.abort("cancelled");
325
+ else if (typeof id === "string" && id.length <= 64) {
326
+ cancelledIds.delete(id);
327
+ cancelledIds.add(id);
328
+ while (cancelledIds.size > CANCELLED_CAP)
329
+ cancelledIds.delete(cancelledIds.values().next().value);
330
+ }
331
+ audit({ commandId: typeof id === "string" ? id : "-", op: "cancel", decision: "ran" });
332
+ return true;
333
+ }
334
+ // `stop` waits its turn like any command, but a running task must not make it wait up to
335
+ // `timeout_s`: tell the task now, so the stop is reached as soon as it unwinds.
336
+ if (header.op === "stop" && header.v === protocol_js_1.DEVICE_PROTOCOL_VERSION) {
337
+ for (const ac of runningTasks.values())
338
+ ac.abort("session_ended");
339
+ }
340
+ queuedIds.set(header.id, (queuedIds.get(header.id) ?? 0) + 1);
341
+ // The deadline runs on THIS device's clock from arrival: the sender's `exp`
342
+ // can only shorten it, never extend it.
343
+ const receivedAt = clock();
344
+ const run = tail.then(() => processCommand(header, body, receivedAt));
345
+ tail = run.catch(() => undefined);
346
+ return run;
347
+ }
348
+ async function processCommand(header, body, receivedAt) {
349
+ const left = (queuedIds.get(header.id) ?? 1) - 1;
350
+ if (left > 0)
351
+ queuedIds.set(header.id, left);
352
+ else
353
+ queuedIds.delete(header.id);
354
+ if (ended)
355
+ return true;
356
+ if (header.v !== protocol_js_1.DEVICE_PROTOCOL_VERSION) {
357
+ await refuse(header, "unsupported", `protocol v=${header.v}`);
358
+ return true;
359
+ }
360
+ // Idempotency: a repeated id returns the stored result, never re-executes.
361
+ const stored = resultCache.get(header.id);
362
+ if (stored) {
363
+ await reply(header, stored.op, stored.body);
364
+ return true;
365
+ }
366
+ if (cancelledIds.delete(header.id)) {
367
+ audit({ commandId: header.id, op: header.op, decision: "refused", reason: "cancelled before start" });
368
+ return true;
369
+ }
370
+ // Expiry: `exp` is required, and the deadline is the device's own: the
371
+ // sooner of the sender's `exp` and arrival + the TTL. Not executed,
372
+ // consumes no seq.
373
+ if (header.exp === undefined) {
374
+ await refuse(header, "bad_args", "exp is required", { expected_seq: lastSeq + 1 });
375
+ return true;
376
+ }
377
+ if (header.exp > receivedAt + protocol_js_1.COMMAND_TTL_MS + EXP_SKEW_MS) {
378
+ // Distinct from bad_args: the sender's clock is behind ours. Carries our
379
+ // `now` so the client can correct its offset and resend the same id.
380
+ await refuse(header, "clock_skew", "exp is further ahead than a command may live", { expected_seq: lastSeq + 1, now: clock() });
381
+ return true;
382
+ }
383
+ const deadline = Math.min(header.exp, receivedAt + protocol_js_1.COMMAND_TTL_MS);
384
+ if (clock() >= deadline) {
385
+ await refuse(header, "expired", "command expired before it started", { expected_seq: lastSeq + 1, now: clock() });
386
+ return true;
387
+ }
388
+ // Paused: the person (or a secure field) has the device. `stop` still works.
389
+ if (pausedReason !== null && header.op !== "stop") {
390
+ await refuse(header, "paused", `paused: ${pausedReason}`, { expected_seq: lastSeq + 1, reason: pausedReason });
391
+ return true;
392
+ }
393
+ // Ordering: strictly increasing seq per sender. A repeat of a KNOWN id was
394
+ // answered from the cache above; any other seq that is not exactly the next
395
+ // one (a gap, or a stale seq from a client that lost its place) is
396
+ // out_of_order, and the agent resyncs to expected_seq.
397
+ if (header.seq !== lastSeq + 1) {
398
+ await refuse(header, "out_of_order", `expected seq ${lastSeq + 1}, got ${header.seq}`, { expected_seq: lastSeq + 1 });
399
+ return true;
400
+ }
401
+ lastSeq = header.seq;
402
+ const op = header.op;
403
+ if (!AGENT_OP_SET.has(op)) {
404
+ await refuse(header, "unsupported", `op ${header.op}`);
405
+ return true;
406
+ }
407
+ if (op === "stop") {
408
+ await reply(header, "ended", { reason: "stopped_by_agent" });
409
+ await stop("stopped_by_agent");
410
+ return true;
411
+ }
412
+ if (op === "shell") {
413
+ await refuse(header, "forbidden", "shell is not available");
414
+ return true;
415
+ }
416
+ // Expiry / duration.
417
+ if (snapshot.expiresAt && Date.now() >= Date.parse(snapshot.expiresAt)) {
418
+ await refuse(header, "out_of_scope", "mandate expired");
419
+ await stop("mandate_expired");
420
+ return true;
421
+ }
422
+ // Capability present?
423
+ const capName = protocol_js_1.CAPABILITY_FOR_OP[op];
424
+ const rule = capName ? snapshot.caps[capName] : undefined;
425
+ if (capName && !rule) {
426
+ await refuse(header, "out_of_scope", `no ${capName} in this mandate`);
427
+ return true;
428
+ }
429
+ // A task needs a handler, and arguments the device would run: checked here so a task that
430
+ // cannot run never takes the approval, the budget or the lock.
431
+ let taskArgs;
432
+ if (op === "task") {
433
+ if (!handlers.task) {
434
+ await refuse(header, "unsupported", "this computer cannot run whole tasks");
435
+ return true;
436
+ }
437
+ try {
438
+ taskArgs = (0, protocol_js_1.parseTaskArgs)(body);
439
+ }
440
+ catch (e) {
441
+ await refuse(header, "bad_args", e instanceof Error ? e.message : "bad task arguments");
442
+ return true;
443
+ }
444
+ }
445
+ // Selector scope: apps and file roots.
446
+ if ((op === "focus_app") && !appInScope(rule, body?.app ?? "")) {
447
+ const app = body?.app ?? "";
448
+ await refuse(header, isNeverAllowed(app) ? "forbidden" : "out_of_scope", `app not in scope: ${app}`);
449
+ return true;
450
+ }
451
+ // Observing a named app is looking at it: the same floor applies.
452
+ if (op === "observe") {
453
+ const app = body?.app;
454
+ if (typeof app === "string" && isNeverAllowed(app)) {
455
+ await refuse(header, "forbidden", `app not in scope: ${app}`);
456
+ return true;
457
+ }
458
+ }
459
+ if (op === "read_file" || op === "write_file") {
460
+ const path = body?.path ?? "";
461
+ const problem = fileScopeProblem(rule, path);
462
+ if (problem) {
463
+ const malformed = problem.startsWith("path must be");
464
+ await refuse(header, malformed ? "forbidden" : "out_of_scope", `path not in scope: ${problem}`);
465
+ return true;
466
+ }
467
+ }
468
+ if (op === "write_file") {
469
+ const content = body?.content_b64 ?? "";
470
+ if (content.length > protocol_js_1.MAX_DEVICE_PLAINTEXT_BYTES) {
471
+ await refuse(header, "too_large", "file exceeds the per-message limit");
472
+ return true;
473
+ }
474
+ }
475
+ // Rate limit.
476
+ const now = Date.now();
477
+ if (!withinRate(op, now)) {
478
+ await refuse(header, "busy", "rate limit");
479
+ return true;
480
+ }
481
+ // Approval mode.
482
+ // A task is not asked about as a whole: the handler asks before each command (ctx.mode).
483
+ const mode = rule?.mode ?? "auto";
484
+ if (mode === "ask" && op !== "task") {
485
+ // Never wait past the command's own deadline.
486
+ let timer;
487
+ const timedOut = new Promise((resolve) => {
488
+ timer = setTimeout(() => resolve("timeout"), Math.max(0, deadline - clock()));
489
+ });
490
+ const asked = callbacks.requestLocalApproval
491
+ ? callbacks.requestLocalApproval(op, body, { timeoutMs: Math.max(0, deadline - clock()) }).catch(() => false)
492
+ : Promise.resolve(false);
493
+ const outcome = await Promise.race([asked, timedOut]);
494
+ if (timer)
495
+ clearTimeout(timer);
496
+ if (outcome === "timeout") {
497
+ await refuse(header, "expired", "command expired while waiting for approval", { expected_seq: lastSeq + 1 });
498
+ return true;
499
+ }
500
+ const approved = outcome;
501
+ if (!approved) {
502
+ await refuse(header, "needs_approval", "waiting for the person at the machine");
503
+ return true;
504
+ }
505
+ }
506
+ // Budget: a budgeted action is any counted op. Check BEFORE running so a
507
+ // spent budget never runs one more.
508
+ const cls = (0, protocol_js_1.countClassForOp)(op);
509
+ // A task is not itself an action: its steps are (ctx.spend), so it is counted but not charged.
510
+ const budgeted = cls !== null && cls !== "error" && op !== "task";
511
+ const checksBudget = budgeted || op === "task";
512
+ if (checksBudget && snapshot.budget?.maxActions !== undefined && spent >= snapshot.budget.maxActions) {
513
+ await refuse(header, "out_of_scope", "action budget spent");
514
+ await stop("budget_exhausted");
515
+ return true;
516
+ }
517
+ // Run the handler (the seam). Mutating ops hold the device-wide mutex so
518
+ // two sessions on one machine never interleave input.
519
+ try {
520
+ // Everything that waited (approval, the device lock, other sessions'
521
+ // actions) can have changed the world: re-check the session, the pause,
522
+ // the mandate and the deadline at the last moment, INSIDE the lock.
523
+ const checked = async () => {
524
+ const gate = lastChance(header, deadline);
525
+ if (gate)
526
+ return gate;
527
+ return dispatch(op, body, header, taskArgs, rule);
528
+ };
529
+ const out = protocol_js_1.MUTATING_OPS.includes(op) ? await deviceLock.run(checked) : await checked();
530
+ if (isGate(out)) {
531
+ // Not executed: give the seq back so the sender can resend this command.
532
+ lastSeq = header.seq - 1;
533
+ if (out.silent)
534
+ return true;
535
+ await refuse(header, out.code, out.message, { expected_seq: header.seq, reason: out.reason });
536
+ return true;
537
+ }
538
+ const result = out;
539
+ if (budgeted)
540
+ spent += 1;
541
+ lastRanAt = new Date(clock()).toISOString();
542
+ lastRanSeq = header.seq;
543
+ if (cls)
544
+ bump(cls);
545
+ audit({ commandId: header.id, op: header.op, decision: "ran" });
546
+ if (mode === "notify")
547
+ callbacks.onNotify?.(op, body);
548
+ remember(header.id, { op: result.op, body: result.body });
549
+ await reply(header, result.op, result.body);
550
+ }
551
+ catch (e) {
552
+ if (e instanceof DeviceBudgetSpentError) {
553
+ // A task spent the last of the budget and did not finish: nothing to count as an error.
554
+ remember(header.id, { op: "error", body: { code: e.code, message: e.message } });
555
+ await refuse(header, e.code, e.message);
556
+ await stop("budget_exhausted");
557
+ return true;
558
+ }
559
+ const code = e instanceof protocol_js_1.DeviceProtocolError ? e.code : "failed";
560
+ const message = e instanceof Error ? e.message : String(e);
561
+ // The person guard (paused / busy) refused INSIDE the handler: nothing
562
+ // ran, so report it as what it is, do not cache it and give the seq back.
563
+ const duck = e;
564
+ if (!(e instanceof protocol_js_1.DeviceProtocolError) && (duck?.code === "paused" || duck?.code === "busy")) {
565
+ lastSeq = header.seq - 1;
566
+ await refuse(header, duck.code, message, {
567
+ expected_seq: header.seq,
568
+ reason: typeof duck.reason === "string" ? duck.reason : undefined,
569
+ });
570
+ return true;
571
+ }
572
+ // The handler ran (or tried to): a retry by id must not run it again.
573
+ remember(header.id, { op: "error", body: { code, message } });
574
+ bump("error");
575
+ await refuse(header, code, message);
576
+ return true;
577
+ }
578
+ // End the session the moment the last budgeted action is spent.
579
+ if (checksBudget && snapshot.budget?.maxActions !== undefined && spent >= snapshot.budget.maxActions) {
580
+ await stop("budget_exhausted");
581
+ }
582
+ return true;
583
+ }
584
+ function isGate(x) {
585
+ return typeof x === "object" && x !== null && x.gate === true;
586
+ }
587
+ /** The last look before a handler runs. */
588
+ function lastChance(header, deadline) {
589
+ if (ended)
590
+ return { gate: true, code: "forbidden", message: "session ended", silent: true };
591
+ if (cancelledIds.delete(header.id)) {
592
+ audit({ commandId: header.id, op: header.op, decision: "refused", reason: "cancelled before start" });
593
+ return { gate: true, code: "forbidden", message: "cancelled", silent: true };
594
+ }
595
+ if (pausedReason !== null) {
596
+ return { gate: true, code: "paused", message: `paused: ${pausedReason}`, reason: pausedReason };
597
+ }
598
+ if (snapshot.expiresAt && clock() >= Date.parse(snapshot.expiresAt)) {
599
+ return { gate: true, code: "out_of_scope", message: "mandate expired" };
600
+ }
601
+ if (clock() >= deadline) {
602
+ return { gate: true, code: "expired", message: "command expired before it could run" };
603
+ }
604
+ return null;
605
+ }
606
+ async function dispatch(op, body, header, taskArgs, rule) {
607
+ switch (op) {
608
+ case "observe":
609
+ return { op: "observation", body: await handlers.observe(body ?? { target: "screen" }) };
610
+ case "click":
611
+ return { op: "ack", body: ackBody(await handlers.click(body)) };
612
+ case "type":
613
+ return { op: "ack", body: ackBody(await handlers.type(body)) };
614
+ case "key":
615
+ return { op: "ack", body: ackBody(await handlers.key(body)) };
616
+ case "scroll":
617
+ return { op: "ack", body: ackBody(await handlers.scroll(body)) };
618
+ case "submit":
619
+ return { op: "ack", body: ackBody(await handlers.submit(body)) };
620
+ case "focus_app":
621
+ return { op: "ack", body: ackBody(await handlers.focusApp(body)) };
622
+ case "list_apps":
623
+ return { op: "apps", body: await handlers.listApps() };
624
+ case "read_file":
625
+ return { op: "file", body: await handlers.readFile(body) };
626
+ case "write_file":
627
+ return { op: "ack", body: ackBody(await handlers.writeFile(body)) };
628
+ case "task":
629
+ return { op: "task_result", body: await runTask(header, taskArgs, rule) };
630
+ default:
631
+ throw new protocol_js_1.DeviceProtocolError("unsupported", op);
632
+ }
633
+ }
634
+ /** Note that something ran just now, so the next beat carries it and Salt moves the lease. */
635
+ function touchLease(header) {
636
+ lastRanAt = new Date(clock()).toISOString();
637
+ lastRanSeq = header.seq;
638
+ }
639
+ /** Run a task handler under the device lock the caller already holds. Progress goes out as
640
+ * non-final results with the task's `re`, always before the final one. */
641
+ async function runTask(header, args, rule) {
642
+ const handler = handlers.task;
643
+ const ac = new AbortController();
644
+ runningTasks.set(header.id, ac);
645
+ const startedAt = clock();
646
+ const timeoutMs = args.timeout_s * 1000;
647
+ let over = false;
648
+ let lastProgressAt = Number.NEGATIVE_INFINITY;
649
+ let pendingProgress = null;
650
+ let progressTimer;
651
+ let postChain = Promise.resolve();
652
+ const sendProgress = (p) => {
653
+ lastProgressAt = clock();
654
+ touchLease(header);
655
+ postChain = postChain
656
+ .then(() => reply(header, "progress", p))
657
+ .then(() => undefined)
658
+ .catch(() => undefined); // a failed progress line never stops the task
659
+ };
660
+ const ctx = {
661
+ signal: ac.signal,
662
+ progress(p) {
663
+ if (over || ended)
664
+ return;
665
+ const n = (0, protocol_js_1.normalizeProgress)(p);
666
+ if (!n)
667
+ return;
668
+ const wait = progressMinIntervalMs - (clock() - lastProgressAt);
669
+ if (wait <= 0) {
670
+ if (progressTimer)
671
+ clearTimeout(progressTimer);
672
+ progressTimer = undefined;
673
+ pendingProgress = null;
674
+ sendProgress(n);
675
+ return;
676
+ }
677
+ // Too soon: keep only the newest and send it when the gap is over.
678
+ pendingProgress = n;
679
+ if (!progressTimer) {
680
+ progressTimer = setTimeout(() => {
681
+ progressTimer = undefined;
682
+ const q = pendingProgress;
683
+ pendingProgress = null;
684
+ if (q && !over && !ended)
685
+ sendProgress(q);
686
+ }, wait);
687
+ }
688
+ },
689
+ spend(cls) {
690
+ if (over)
691
+ throw new Error("the task is over");
692
+ if (ended)
693
+ throw new protocol_js_1.DeviceProtocolError("failed", "session ended");
694
+ const counted = SPEND_COUNT[cls];
695
+ if (!counted)
696
+ throw new protocol_js_1.DeviceProtocolError("bad_args", `unknown step class ${String(cls)}`);
697
+ const max = snapshot.budget?.maxActions;
698
+ if (max !== undefined && spent >= max)
699
+ throw new DeviceBudgetSpentError();
700
+ spent += 1;
701
+ touchLease(header);
702
+ bump(counted);
703
+ },
704
+ paused: () => pausedReason,
705
+ waitWhilePaused() {
706
+ if (pausedReason === null)
707
+ return Promise.resolve("running");
708
+ const outcome = () => (ac.signal.reason === "timeout" ? "paused_out" : "stopped");
709
+ if (ac.signal.aborted)
710
+ return Promise.resolve(outcome());
711
+ return new Promise((resolve) => {
712
+ const done = (v) => {
713
+ resumeWaiters.delete(onResume);
714
+ ac.signal.removeEventListener("abort", onAbort);
715
+ resolve(v);
716
+ };
717
+ const onResume = () => done("running");
718
+ const onAbort = () => done(outcome());
719
+ resumeWaiters.add(onResume);
720
+ ac.signal.addEventListener("abort", onAbort, { once: true });
721
+ });
722
+ },
723
+ mode: rule?.mode ?? "auto",
724
+ caps: Object.freeze({ ...snapshot.caps }),
725
+ };
726
+ const timers = [];
727
+ timers.push(setTimeout(() => ac.abort("timeout"), timeoutMs));
728
+ if (snapshot.expiresAt) {
729
+ const until = Date.parse(snapshot.expiresAt) - clock();
730
+ if (until < timeoutMs)
731
+ timers.push(setTimeout(() => ac.abort("session_ended"), Math.max(0, until)));
732
+ }
733
+ let hardTimer;
734
+ let outcome;
735
+ try {
736
+ const ran = (async () => handler(args, ctx))().then((value) => ({ kind: "ok", value }), (error) => ({ kind: "err", error }));
737
+ const hard = new Promise((resolve) => {
738
+ hardTimer = setTimeout(() => resolve({ kind: "hard" }), timeoutMs + taskGraceMs);
739
+ });
740
+ outcome = await Promise.race([ran, hard]);
741
+ }
742
+ finally {
743
+ for (const t of timers)
744
+ clearTimeout(t);
745
+ if (hardTimer)
746
+ clearTimeout(hardTimer);
747
+ }
748
+ over = true;
749
+ if (progressTimer)
750
+ clearTimeout(progressTimer);
751
+ pendingProgress = null;
752
+ runningTasks.delete(header.id);
753
+ if (outcome.kind === "hard")
754
+ ac.abort("timeout"); // a handler that ignored the signal is let go
755
+ await postChain; // every progress line goes out before the final result
756
+ const elapsed = Math.max(0, clock() - startedAt);
757
+ const cut = (status, summary) => ({
758
+ task_id: (0, protocol_js_1.newTaskId)(),
759
+ status,
760
+ summary,
761
+ checked: null,
762
+ steps: [],
763
+ commands: 0,
764
+ duration_ms: elapsed,
765
+ });
766
+ if (outcome.kind === "ok")
767
+ return (0, protocol_js_1.normalizeTaskResult)(outcome.value, { duration_ms: elapsed });
768
+ if (outcome.kind === "hard")
769
+ return cut("timeout", "The task ran out of time.");
770
+ if (outcome.error instanceof DeviceBudgetSpentError)
771
+ throw outcome.error;
772
+ if (ac.signal.aborted) {
773
+ // The handler gave up because it was told to: that is a stop or a timeout, not a failure.
774
+ return ac.signal.reason === "timeout" ? cut("timeout", "The task ran out of time.") : cut("stopped", "The task was stopped.");
775
+ }
776
+ throw outcome.error;
777
+ }
778
+ function start() {
779
+ if (ended || beatTimer)
780
+ return;
781
+ beatTimer = setInterval(async () => {
782
+ void syncPause(); // retry a pause/resume report that failed
783
+ try {
784
+ // Beats keep running while paused: Salt ends a paused session that stops
785
+ // beating after 15 s. After a command has run, every beat says so.
786
+ const report = {
787
+ ...(lastRanAt !== null ? { last_command_at: lastRanAt, last_seq: lastRanSeq ?? undefined } : {}),
788
+ ...(runningTasks.size > 0 ? { task_running: true } : {}),
789
+ };
790
+ await transport.beat(snapshot.sessionId, Object.keys(report).length ? report : undefined);
791
+ }
792
+ catch (e) {
793
+ if (e instanceof DeviceBeatOverError) {
794
+ await stop("lost_contact");
795
+ }
796
+ // any other beat error: keep trying until the dead-man sweep ends us.
797
+ }
798
+ }, beatIntervalMs);
799
+ if (typeof beatTimer.unref === "function")
800
+ beatTimer.unref();
801
+ if (snapshot.budget?.maxDurationS !== undefined) {
802
+ const ms = Math.max(0, snapshot.budget.maxDurationS * 1000 - (Date.now() - startedAt));
803
+ durationTimer = setTimeout(() => void stop("duration_limit"), ms);
804
+ if (typeof durationTimer.unref === "function")
805
+ durationTimer.unref();
806
+ }
807
+ }
808
+ async function stop(reason = "stopped_by_device") {
809
+ if (ended)
810
+ return;
811
+ ended = true;
812
+ if (beatTimer)
813
+ clearInterval(beatTimer);
814
+ if (durationTimer)
815
+ clearTimeout(durationTimer);
816
+ beatTimer = durationTimer = null;
817
+ for (const ac of runningTasks.values())
818
+ ac.abort("session_ended");
819
+ audit({ commandId: "-", op: "stop", decision: "ended", reason });
820
+ callbacks.onEnded?.(reason);
821
+ try {
822
+ await transport.stop(snapshot.sessionId, reason);
823
+ }
824
+ catch {
825
+ /* subtract-only + idempotent; a failed stop still ends us locally */
826
+ }
827
+ }
828
+ /** Make Salt's picture of our pause state match ours. Salt only accepts a
829
+ * pause from `active`, so a reason CHANGE is resume-then-pause. A failed
830
+ * report is retried on the next beat (the local pause stands meanwhile). */
831
+ function syncPause() {
832
+ pauseSync = pauseSync.then(async () => {
833
+ if (ended)
834
+ return;
835
+ const want = pausedReason;
836
+ if (reportedPause === want)
837
+ return;
838
+ try {
839
+ if (reportedPause !== null) {
840
+ await transport.resume?.(snapshot.sessionId);
841
+ reportedPause = null;
842
+ }
843
+ if (want !== null) {
844
+ await transport.pause?.(snapshot.sessionId, want);
845
+ reportedPause = want;
846
+ }
847
+ }
848
+ catch {
849
+ /* retried on the next beat */
850
+ }
851
+ });
852
+ return pauseSync;
853
+ }
854
+ async function pause(reason) {
855
+ if (ended)
856
+ return;
857
+ const changed = pausedReason !== reason;
858
+ pausedReason = reason;
859
+ audit({ commandId: "-", op: "pause", decision: "refused", reason });
860
+ if (!changed)
861
+ return;
862
+ await syncPause();
863
+ }
864
+ async function resume() {
865
+ if (ended || pausedReason === null)
866
+ return;
867
+ pausedReason = null;
868
+ audit({ commandId: "-", op: "resume", decision: "ran" });
869
+ for (const release of [...resumeWaiters])
870
+ release();
871
+ await syncPause();
872
+ }
873
+ return { handleCommand, start, stop, actionsSpent: () => spent, pause, resume, pausedReason: () => pausedReason };
874
+ }
875
+ const AGENT_OP_SET = new Set([
876
+ "observe",
877
+ "click",
878
+ "type",
879
+ "key",
880
+ "scroll",
881
+ "submit",
882
+ "focus_app",
883
+ "list_apps",
884
+ "read_file",
885
+ "write_file",
886
+ "task",
887
+ "stop",
888
+ "cancel",
889
+ "shell",
890
+ ]);
891
+ //# sourceMappingURL=host.js.map