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.
- 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 +3123 -35
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1267 -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 +627 -28
- package/dist/webhook.d.ts.map +1 -1
- package/dist/webhook.js +1388 -84
- 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
|
@@ -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
|