aegis-desktop 1.0.13 → 1.0.15
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/SOURCE-OFFER.txt +9 -0
- package/bin/aegis.js +1 -32
- package/lib/account-key.js +1 -56
- package/lib/auto.js +1 -430
- package/lib/avatar/companion.js +1 -698
- package/lib/avatar/cosmetics.js +1 -532
- package/lib/avatar/events.js +1 -344
- package/lib/avatar/holder-turn.js +1 -350
- package/lib/avatar/holders.js +1 -612
- package/lib/avatar/identity.js +1 -641
- package/lib/avatar/level.js +1 -368
- package/lib/avatar/mirror.js +1 -524
- package/lib/avatar/persona.js +1 -388
- package/lib/avatar/profile.js +1 -977
- package/lib/avatar/register.js +1 -307
- package/lib/avatar/store.js +1 -472
- package/lib/avatar/tiers.js +1 -38
- package/lib/avatar/transfer.js +1 -415
- package/lib/avatar/turn.js +1 -216
- package/lib/avatar/voice.js +1 -328
- package/lib/avatar/xp.js +1 -404
- package/lib/deep-link.js +1 -120
- package/lib/fingerprint.js +2 -347
- package/lib/google-connect.js +1 -169
- package/lib/local/agents.js +1 -105
- package/lib/local/autonomous.js +1 -1045
- package/lib/local/claude-pro.js +1 -993
- package/lib/local/context.js +1 -95
- package/lib/local/engine.js +1 -3228
- package/lib/local/entitlement.js +1 -654
- package/lib/local/git-scope.js +1 -228
- package/lib/local/local.js +1 -2135
- package/lib/local/prompt.js +1 -275
- package/lib/local/queue.js +1 -560
- package/lib/local/session-rounds.js +1 -208
- package/lib/local/shell.js +1 -680
- package/lib/local/telemetry.js +1 -470
- package/lib/local/tools.js +1 -1238
- package/lib/local/turn-guard.js +1 -300
- package/lib/local/worktree-lock.js +1 -235
- package/lib/provider-key-mirror.js +1 -252
- package/lib/quick-launcher.js +1 -84
- package/lib/settings.js +1 -890
- package/lib/sync/memory-queue.js +1 -60
- package/lib/sync/persist-gate.js +1 -148
- package/lib/sync/sessions.js +1 -106
- package/lib/window-state.js +1 -66
- package/main.js +3 -4390
- package/package.json +3 -5
- package/preload.js +3 -628
- package/renderer/app.js +1 -6560
- package/renderer/avatar/assemble.js +1 -335
- package/renderer/avatar/avatar.js +1 -380
- package/renderer/avatar/cards.js +1 -141
- package/renderer/avatar/hud.js +1 -206
- package/renderer/avatar/machine.js +1 -213
- package/renderer/avatar/pane.js +1 -439
- package/renderer/avatar/parts.js +1 -437
- package/renderer/budget.js +1 -168
- package/renderer/companion.js +1 -223
- package/renderer/diff.js +1 -359
- package/renderer/diffview.js +1 -336
- package/renderer/fingerprint.js +3 -720
- package/renderer/markdown.js +1 -168
- package/renderer/provider-toolbar.js +1 -134
- package/renderer/providers.js +1 -259
- package/renderer/quick.js +1 -191
- package/renderer/stream-policy.js +1 -306
- package/renderer/transcript-view.js +1 -323
- package/renderer/usage.js +1 -1239
- package/vendor/aegis.js +1 -2054
- package/vendor/attest.js +1 -442
- package/vendor/authorship-key.js +1 -410
- package/vendor/auto-provision.js +1 -134
- package/vendor/brain-catalog.js +1 -268
- package/vendor/byok-catalog.js +1 -230
- package/vendor/credentials.js +1 -494
- package/vendor/device-fingerprint.js +1 -141
- package/vendor/drive-connect.js +1 -85
- package/vendor/drive-upload.js +1 -174
- package/vendor/env-file.js +1 -1273
- package/vendor/fingerprint.js +1 -1064
- package/vendor/foreign-memory.js +1 -669
- package/vendor/google-signin.js +1 -125
- package/vendor/hooks.js +1 -430
- package/vendor/model-discovery.js +1 -330
- package/vendor/platform.js +1 -937
- package/vendor/prefs.js +1 -351
- package/vendor/presence-constants.js +1 -107
- package/vendor/presence.js +1 -438
- package/vendor/record-vault.js +3 -1055
- package/vendor/session-store.js +1 -548
- package/vendor/topup.js +1 -297
- package/vendor/touch.js +1 -458
- package/vendor/update.js +1 -704
- package/vendor/user-models.js +1 -381
package/vendor/presence.js
CHANGED
|
@@ -1,440 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
|
|
4
|
-
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
-
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Client presence beacon — the client half of aegis1's `/api/v1/heartbeat`.
|
|
10
|
-
*
|
|
11
|
-
* WHAT IT IS FOR. aegis1 already had `/api/heartbeat` (the dashboard's own JS
|
|
12
|
-
* pinging to bump `users.last_active_at`). What it did NOT have was any way to
|
|
13
|
-
* answer "who is running aegiscode right now, which app, which build, how many
|
|
14
|
-
* machines?" — a user sitting in a session but not making model calls was
|
|
15
|
-
* invisible. `heartbeat_v1()` in aegis1/app.py closes that gap by writing a
|
|
16
|
-
* `client_presence` row keyed on (user_id, app, install), and this module is
|
|
17
|
-
* the beacon that feeds it. Same for both hosts: the CLI's `aegiscode` binary
|
|
18
|
-
* and the desktop's main process require this one file.
|
|
19
|
-
*
|
|
20
|
-
* THE SERVER CONTRACT (aegis1/app.py `heartbeat_v1`, do not change one side
|
|
21
|
-
* without the other):
|
|
22
|
-
*
|
|
23
|
-
* POST /api/v1/heartbeat X-API-Key: <the user's aegis key>
|
|
24
|
-
* {"app": "desktop", "version": "0.8.12", "platform": "linux",
|
|
25
|
-
* "install": "9f3c1a2b4d5e6f70", "queue": 2, "autonomous": false}
|
|
26
|
-
* -> 200 {"ok": true, "online": true, "seen_at": ..., "beat_interval_s": 300}
|
|
27
|
-
*
|
|
28
|
-
* Auth is the ordinary `@login_required`, which already accepts X-API-Key or
|
|
29
|
-
* Bearer — the same key every host signs in with. So the beacon introduces NO
|
|
30
|
-
* second credential and no new client secret, and a host with no key has
|
|
31
|
-
* nothing to beacon AT: `apiKey` absent means this module is inert by
|
|
32
|
-
* construction (see `disabledPresence()`), never a 401 loop.
|
|
33
|
-
*
|
|
34
|
-
* `install` IS THE FINGERPRINT. Not a second identifier invented here: it is
|
|
35
|
-
* `machineFingerprint()` from ./device-fingerprint.js, the same SHA-256 the
|
|
36
|
-
* device-registration path uses. That is deliberate and it is the property the
|
|
37
|
-
* admin panel needs — "how many machines does this user run" has to count
|
|
38
|
-
* machines, so the beacon must carry the machine's identity, not a random
|
|
39
|
-
* per-run token that would make one laptop look like fifty. It is a 64-char
|
|
40
|
-
* lowercase hex digest and aegis1's `_presence_token(install, 64)` accepts
|
|
41
|
-
* exactly that width. Note what this does and does not imply: it is a stable
|
|
42
|
-
* pseudonym for a machine, so a user's own rows join up; it is NOT a
|
|
43
|
-
* cross-account tracking device and the server never sees the raw machine id it
|
|
44
|
-
* was derived from.
|
|
45
|
-
*
|
|
46
|
-
* FOUR PROPERTIES, in order of importance — the same discipline
|
|
47
|
-
* `lib/local/telemetry.js` is built around, for the same reason:
|
|
48
|
-
*
|
|
49
|
-
* 1. It cannot break the host. `beat()` returns a boolean and NEVER throws,
|
|
50
|
-
* whatever the network, the DNS, the JSON, or an injected `fetch` does.
|
|
51
|
-
* A beacon that can take down a CLI session or an Electron main process
|
|
52
|
-
* is worse than no beacon at all.
|
|
53
|
-
* 2. It cannot leak. The request body is rebuilt field by field from the
|
|
54
|
-
* arguments — never spread, never `JSON.stringify(callerObject)` — and
|
|
55
|
-
* only `app`, `version`, `platform`, `install`, `queue`, `autonomous` can
|
|
56
|
-
* ever be sent. No conversation content, no file paths, no model output,
|
|
57
|
-
* no email, no prompt text. `sane()` additionally truncates each field to
|
|
58
|
-
* the same width the server enforces, so a caller cannot smuggle a blob
|
|
59
|
-
* through a field this module owns.
|
|
60
|
-
* 3. It cannot keep the process alive. The timer is `unref()`d, so `stop()`
|
|
61
|
-
* is a convenience, not a requirement: an interrupted CLI exits on time
|
|
62
|
-
* even if nobody ever calls it, and the desktop's quit path does not have
|
|
63
|
-
* to thread a handle through.
|
|
64
|
-
* 4. It cannot hammer. The cadence is the SERVER's (`beat_interval_s` in the
|
|
65
|
-
* response), so the beacon can be slowed or sped up without shipping a
|
|
66
|
-
* client release; failures back off exponentially instead of retrying on
|
|
67
|
-
* the same rhythm, which is what turns a dead endpoint from a client
|
|
68
|
-
* problem into a server problem.
|
|
69
|
-
*
|
|
70
|
-
* Nothing in this file may import Electron: it is plain Node so the CLI, the
|
|
71
|
-
* desktop main process and the tests can all drive the same code. Each host
|
|
72
|
-
* injects its own `app` name, version, platform and queue depth.
|
|
73
|
-
*/
|
|
74
|
-
|
|
75
|
-
const {
|
|
76
|
-
PRESENCE_PATH,
|
|
77
|
-
DEFAULT_INTERVAL_MS,
|
|
78
|
-
MIN_INTERVAL_MS,
|
|
79
|
-
MAX_INTERVAL_MS,
|
|
80
|
-
BACKOFF_FACTOR,
|
|
81
|
-
MAX_BACKOFF_MS,
|
|
82
|
-
REQUEST_TIMEOUT_MS,
|
|
83
|
-
APP_NAMES,
|
|
84
|
-
saneToken,
|
|
85
|
-
saneCount,
|
|
86
|
-
} = require('./presence-constants.js');
|
|
87
|
-
|
|
88
|
-
const DEFAULT_API_BASE = 'https://aegiscloud.org';
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The store returned when there is nothing to beacon (no key, or a host that
|
|
92
|
-
* explicitly opted out). Every method answers, nothing happens, no timer is
|
|
93
|
-
* created and no request is made: `enabled` is false and `beat()` is a no-op
|
|
94
|
-
* that reports `false`. Checked by test/presence.test.mjs.
|
|
95
|
-
*/
|
|
96
|
-
function disabledPresence(reason) {
|
|
97
|
-
const state = {
|
|
98
|
-
enabled: false,
|
|
99
|
-
reason: String(reason || 'disabled'),
|
|
100
|
-
beats: 0,
|
|
101
|
-
failures: 0,
|
|
102
|
-
lastError: null,
|
|
103
|
-
lastSeenAt: null,
|
|
104
|
-
intervalMs: null,
|
|
105
|
-
install: null,
|
|
106
|
-
app: null,
|
|
107
|
-
running: false,
|
|
108
|
-
};
|
|
109
|
-
return {
|
|
110
|
-
enabled: false,
|
|
111
|
-
reason: state.reason,
|
|
112
|
-
install: null,
|
|
113
|
-
beat: () => Promise.resolve(false),
|
|
114
|
-
start: () => false,
|
|
115
|
-
stop: () => false,
|
|
116
|
-
state: () => ({ ...state }),
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** One ISO-8601 UTC string, or null. Never throws on a hostile clock. */
|
|
121
|
-
function isoOf(ms) {
|
|
122
|
-
try {
|
|
123
|
-
const n = Number(ms);
|
|
124
|
-
if (!Number.isFinite(n)) return null;
|
|
125
|
-
return new Date(n).toISOString();
|
|
126
|
-
} catch {
|
|
127
|
-
return null;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* @param {object} opts
|
|
133
|
-
* @param {string} [opts.apiKey] the aegis key; absent ⇒ inert
|
|
134
|
-
* @param {string} [opts.baseUrl] defaults to https://aegiscloud.org
|
|
135
|
-
* @param {string} [opts.app] 'desktop' | 'cli' | ... (see APP_NAMES)
|
|
136
|
-
* @param {string} [opts.version] the host's own build string
|
|
137
|
-
* @param {string} [opts.platform] process.platform
|
|
138
|
-
* @param {string} [opts.install] machine fingerprint (default: computed)
|
|
139
|
-
* @param {number|function} [opts.queueDepth] pending queue tasks (may be a fn)
|
|
140
|
-
* @param {boolean|function} [opts.autonomous] a drain is running (may be a fn)
|
|
141
|
-
* @param {number} [opts.intervalMs] initial cadence before the server answers
|
|
142
|
-
* @param {boolean} [opts.enabled] explicit kill switch (default true)
|
|
143
|
-
* @param {string} [opts.clientVersion] X-AEGIS-Version header (version gate)
|
|
144
|
-
* @param {function} [opts.fetchImpl] fetch override (tests)
|
|
145
|
-
* @param {function} [opts.now] clock override (tests)
|
|
146
|
-
* @param {function} [opts.log] (level, message, err) => void
|
|
147
|
-
* @param {function} [opts.setTimer] setTimeout override (tests)
|
|
148
|
-
* @param {function} [opts.clearTimer] clearTimeout override (tests)
|
|
149
|
-
*/
|
|
150
|
-
function createPresence(opts = {}) {
|
|
151
|
-
const apiKey = typeof opts.apiKey === 'string' ? opts.apiKey.trim() : '';
|
|
152
|
-
if (!apiKey) return disabledPresence('no-api-key');
|
|
153
|
-
if (opts.enabled === false) return disabledPresence('disabled');
|
|
154
|
-
|
|
155
|
-
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
156
|
-
// A logger that throws is not allowed to matter — same rule as telemetry.js.
|
|
157
|
-
function debugOnce(kind) {
|
|
158
|
-
if (logged.has(kind)) return;
|
|
159
|
-
logged.add(kind);
|
|
160
|
-
try {
|
|
161
|
-
log('debug', `presence: ${kind}`);
|
|
162
|
-
} catch {
|
|
163
|
-
/* a broken logger is not an error path */
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
const logged = new Set();
|
|
167
|
-
|
|
168
|
-
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
169
|
-
const setTimer = typeof opts.setTimer === 'function' ? opts.setTimer : setTimeout;
|
|
170
|
-
const clearTimer = typeof opts.clearTimer === 'function' ? opts.clearTimer : clearTimeout;
|
|
171
|
-
|
|
172
|
-
// The fingerprint. Required lazily so a host that never beacons (the tests
|
|
173
|
-
// for the other modules) does not touch the filesystem at require time.
|
|
174
|
-
//
|
|
175
|
-
// An injected `fingerprint` is an IDENTITY SOURCE, not a first choice: when a
|
|
176
|
-
// host hands one in, that is the machine's identity and the built-in ladder
|
|
177
|
-
// is not consulted. That matters for the packaged hosts, which resolve the
|
|
178
|
-
// fingerprint once at their own call site (cli/src/presence.js, desktop
|
|
179
|
-
// main.js) and must be able to fail loudly if it cannot — a silent fallback
|
|
180
|
-
// here would hide a broken identity by substituting a different one, and the
|
|
181
|
-
// panel would count the same laptop as two machines.
|
|
182
|
-
let install = saneToken(opts.install, 64);
|
|
183
|
-
if (!install && typeof opts.fingerprint === 'function') {
|
|
184
|
-
try {
|
|
185
|
-
install = saneToken(opts.fingerprint(), 64);
|
|
186
|
-
} catch {
|
|
187
|
-
debugOnce('fingerprint');
|
|
188
|
-
}
|
|
189
|
-
} else if (!install) {
|
|
190
|
-
try {
|
|
191
|
-
install = saneToken(require('./device-fingerprint.js').machineFingerprint(), 64);
|
|
192
|
-
} catch {
|
|
193
|
-
debugOnce('fingerprint');
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
if (!install) {
|
|
197
|
-
// No machine identity could be resolved at all (an exotic sandbox). A
|
|
198
|
-
// beacon without an install key would write a NEW presence row on every
|
|
199
|
-
// beat — one user, infinite machines in the panel. Better to stay out of
|
|
200
|
-
// the table than to poison it: see device-fingerprint.js for the ladder
|
|
201
|
-
// (machine id -> persisted install id -> hostname) that makes this rare.
|
|
202
|
-
return disabledPresence('no-install-identity');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const app = saneToken(opts.app, 24).toLowerCase() || 'other';
|
|
206
|
-
const version = saneToken(opts.version, 32);
|
|
207
|
-
const rawPlatform = saneToken(opts.platform, 16).toLowerCase();
|
|
208
|
-
const platform = rawPlatform || (typeof process !== 'undefined' && process.platform ? process.platform : '');
|
|
209
|
-
const baseUrl = String(opts.baseUrl || (typeof process !== 'undefined' && process.env && process.env.AEGIS_API_BASE) || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
210
|
-
const path = PRESENCE_PATH;
|
|
211
|
-
const fetchImpl =
|
|
212
|
-
typeof opts.fetchImpl === 'function'
|
|
213
|
-
? opts.fetchImpl
|
|
214
|
-
: typeof fetch === 'function'
|
|
215
|
-
? fetch
|
|
216
|
-
: null;
|
|
217
|
-
|
|
218
|
-
/** A caller passing a function is asking to be read at send time, so the
|
|
219
|
-
* beacon reports the LIVE queue depth rather than the depth at boot. */
|
|
220
|
-
function readDynamic(value, fallback) {
|
|
221
|
-
try {
|
|
222
|
-
return typeof value === 'function' ? value() : value;
|
|
223
|
-
} catch {
|
|
224
|
-
return fallback;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
let intervalMs = clampInterval(opts.intervalMs);
|
|
229
|
-
const state = {
|
|
230
|
-
enabled: true,
|
|
231
|
-
reason: null,
|
|
232
|
-
beats: 0,
|
|
233
|
-
failures: 0,
|
|
234
|
-
lastError: null,
|
|
235
|
-
lastSeenAt: null,
|
|
236
|
-
intervalMs,
|
|
237
|
-
install,
|
|
238
|
-
app,
|
|
239
|
-
version: version || null,
|
|
240
|
-
platform: platform || null,
|
|
241
|
-
running: false,
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
let timer = null;
|
|
245
|
-
let inflight = null;
|
|
246
|
-
let backoffMs = 0;
|
|
247
|
-
let stopped = false;
|
|
248
|
-
|
|
249
|
-
function clampInterval(ms) {
|
|
250
|
-
const n = Number(ms);
|
|
251
|
-
if (!Number.isFinite(n) || n <= 0) return DEFAULT_INTERVAL_MS;
|
|
252
|
-
return Math.min(MAX_INTERVAL_MS, Math.max(MIN_INTERVAL_MS, Math.floor(n)));
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** The ONLY body shape this module can ever send. Field by field, on purpose. */
|
|
256
|
-
function body() {
|
|
257
|
-
return {
|
|
258
|
-
app,
|
|
259
|
-
version: version || undefined,
|
|
260
|
-
platform: platform || undefined,
|
|
261
|
-
install,
|
|
262
|
-
queue: saneCount(readDynamic(opts.queueDepth, 0)),
|
|
263
|
-
autonomous: Boolean(readDynamic(opts.autonomous, false)),
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Send one beat. Resolves `true` when the server acknowledged, `false` on
|
|
269
|
-
* every other outcome — no key, offline, DNS failure, 500, a malformed body,
|
|
270
|
-
* an aborted request. It never rejects, so a caller may `void beat()` without
|
|
271
|
-
* an unhandled-rejection guard, and a concurrent call while one is in flight
|
|
272
|
-
* is answered by the in-flight promise rather than by a second request (two
|
|
273
|
-
* beacons racing would double-count `beats` on the server).
|
|
274
|
-
*/
|
|
275
|
-
function beat() {
|
|
276
|
-
if (stopped) return Promise.resolve(false);
|
|
277
|
-
if (inflight) return inflight;
|
|
278
|
-
if (!fetchImpl) {
|
|
279
|
-
debugOnce('no-fetch');
|
|
280
|
-
state.lastError = 'no fetch implementation';
|
|
281
|
-
state.failures += 1;
|
|
282
|
-
return Promise.resolve(false);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const startedAt = now();
|
|
286
|
-
inflight = (async () => {
|
|
287
|
-
let timedOut = false;
|
|
288
|
-
let cancel = null;
|
|
289
|
-
try {
|
|
290
|
-
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
291
|
-
if (controller) {
|
|
292
|
-
const t = setTimer(() => {
|
|
293
|
-
timedOut = true;
|
|
294
|
-
try {
|
|
295
|
-
controller.abort();
|
|
296
|
-
} catch {
|
|
297
|
-
/* already gone */
|
|
298
|
-
}
|
|
299
|
-
}, REQUEST_TIMEOUT_MS);
|
|
300
|
-
if (t && typeof t.unref === 'function') t.unref();
|
|
301
|
-
cancel = () => clearTimer(t);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const res = await fetchImpl(`${baseUrl}${path}`, {
|
|
305
|
-
method: 'POST',
|
|
306
|
-
headers: {
|
|
307
|
-
'Content-Type': 'application/json',
|
|
308
|
-
'X-API-Key': apiKey,
|
|
309
|
-
'X-AEGIS-Version': saneToken(opts.clientVersion, 32) || version || '',
|
|
310
|
-
},
|
|
311
|
-
body: JSON.stringify(body()),
|
|
312
|
-
...(controller ? { signal: controller.signal } : {}),
|
|
313
|
-
});
|
|
314
|
-
if (cancel) cancel();
|
|
315
|
-
|
|
316
|
-
// `ok` on the response, not `res.ok === true` semantics: a 404 (server
|
|
317
|
-
// predates the route) and a 401 (key revoked) are both just "not this
|
|
318
|
-
// time" — the beacon must not retry storm either of them.
|
|
319
|
-
if (!res || typeof res.status !== 'number' || res.status < 200 || res.status >= 300) {
|
|
320
|
-
state.failures += 1;
|
|
321
|
-
state.lastError = `http ${res && res.status ? res.status : 'error'}`;
|
|
322
|
-
backoffMs = nextBackoff(backoffMs);
|
|
323
|
-
return false;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
let data = null;
|
|
327
|
-
try {
|
|
328
|
-
data = await res.json();
|
|
329
|
-
} catch {
|
|
330
|
-
// A 200 with an unreadable body is still a successful beat: the
|
|
331
|
-
// server wrote the row before it serialised the answer. Only the
|
|
332
|
-
// cadence hint is lost, and the current one stays in force.
|
|
333
|
-
data = null;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
state.beats += 1;
|
|
337
|
-
state.failures = 0;
|
|
338
|
-
state.lastError = null;
|
|
339
|
-
state.lastSeenAt = isoOf(now());
|
|
340
|
-
backoffMs = 0;
|
|
341
|
-
// The server owns the cadence. An unrecognised/hostile value is clamped
|
|
342
|
-
// rather than trusted: a `beat_interval_s` of 0 would be a hot loop.
|
|
343
|
-
if (data && typeof data === 'object' && data.beat_interval_s != null) {
|
|
344
|
-
const secs = Number(data.beat_interval_s);
|
|
345
|
-
if (Number.isFinite(secs) && secs > 0) intervalMs = clampInterval(secs * 1000);
|
|
346
|
-
}
|
|
347
|
-
state.intervalMs = intervalMs;
|
|
348
|
-
return true;
|
|
349
|
-
} catch (err) {
|
|
350
|
-
if (cancel) cancel();
|
|
351
|
-
state.failures += 1;
|
|
352
|
-
state.lastError = timedOut ? 'timeout' : String((err && err.message) || err || 'error').slice(0, 200);
|
|
353
|
-
backoffMs = nextBackoff(backoffMs);
|
|
354
|
-
if (state.failures === 1) debugOnce('beat-failed'); // once, then quiet
|
|
355
|
-
return false;
|
|
356
|
-
} finally {
|
|
357
|
-
inflight = null;
|
|
358
|
-
if (state.running) schedule(Math.max(0, intervalMs + backoffMs - (now() - startedAt)));
|
|
359
|
-
}
|
|
360
|
-
})();
|
|
361
|
-
|
|
362
|
-
return inflight;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function nextBackoff(current) {
|
|
366
|
-
const base = current > 0 ? current * BACKOFF_FACTOR : Math.max(MIN_INTERVAL_MS, intervalMs);
|
|
367
|
-
return Math.min(MAX_BACKOFF_MS, Math.floor(base));
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** One timer at a time, always unref'd, always cleared before replacing. */
|
|
371
|
-
function schedule(delayMs) {
|
|
372
|
-
if (stopped || typeof setTimer !== 'function') return;
|
|
373
|
-
if (timer) clearTimer(timer);
|
|
374
|
-
const delay = Math.max(1000, Math.floor(Number(delayMs) || intervalMs));
|
|
375
|
-
timer = setTimer(() => {
|
|
376
|
-
timer = null;
|
|
377
|
-
void beat();
|
|
378
|
-
}, delay);
|
|
379
|
-
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* Start beaconing and send the FIRST beat immediately. A host that is opened
|
|
384
|
-
* and closed in under a minute still registers — waiting one full interval
|
|
385
|
-
* before the first beat would make the panel blind to exactly the short
|
|
386
|
-
* sessions the route was added for.
|
|
387
|
-
*/
|
|
388
|
-
function start() {
|
|
389
|
-
if (stopped) return false;
|
|
390
|
-
state.running = true;
|
|
391
|
-
void beat();
|
|
392
|
-
return true;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/** Stop beaconing. Idempotent, and safe to call when start() never ran. */
|
|
396
|
-
function stop() {
|
|
397
|
-
stopped = true;
|
|
398
|
-
state.running = false;
|
|
399
|
-
if (timer) {
|
|
400
|
-
try {
|
|
401
|
-
clearTimer(timer);
|
|
402
|
-
} catch {
|
|
403
|
-
/* a timer that already fired is not an error */
|
|
404
|
-
}
|
|
405
|
-
timer = null;
|
|
406
|
-
}
|
|
407
|
-
return true;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return {
|
|
411
|
-
enabled: true,
|
|
412
|
-
app,
|
|
413
|
-
install,
|
|
414
|
-
beat,
|
|
415
|
-
start,
|
|
416
|
-
stop,
|
|
417
|
-
/**
|
|
418
|
-
* The in-flight beat, or null. Exposed for one reason: a SHORT-LIVED host
|
|
419
|
-
* (`aegiscode -p "…"`, `aegiscode autonomous run`) finishes its work in
|
|
420
|
-
* well under a second and calls `process.exit()`, which kills an in-flight
|
|
421
|
-
* request and loses the beat entirely — the machine is silently absent from
|
|
422
|
-
* the panel even though it just ran. The CLI awaits this before exiting
|
|
423
|
-
* (see `flush()` in cli/src/presence.js). A long-lived host can ignore it.
|
|
424
|
-
*/
|
|
425
|
-
pending: () => inflight,
|
|
426
|
-
state: () => ({ ...state, intervalMs }),
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
module.exports = {
|
|
431
|
-
createPresence,
|
|
432
|
-
disabledPresence,
|
|
433
|
-
PRESENCE_PATH,
|
|
434
|
-
DEFAULT_INTERVAL_MS,
|
|
435
|
-
MIN_INTERVAL_MS,
|
|
436
|
-
MAX_INTERVAL_MS,
|
|
437
|
-
MAX_BACKOFF_MS,
|
|
438
|
-
REQUEST_TIMEOUT_MS,
|
|
439
|
-
APP_NAMES,
|
|
440
|
-
};
|
|
3
|
+
"use strict";const{PRESENCE_PATH:PRESENCE_PATH,DEFAULT_INTERVAL_MS:DEFAULT_INTERVAL_MS,MIN_INTERVAL_MS:MIN_INTERVAL_MS,MAX_INTERVAL_MS:MAX_INTERVAL_MS,BACKOFF_FACTOR:BACKOFF_FACTOR,MAX_BACKOFF_MS:MAX_BACKOFF_MS,REQUEST_TIMEOUT_MS:REQUEST_TIMEOUT_MS,APP_NAMES:APP_NAMES,saneToken:saneToken,saneCount:saneCount}=require("./presence-constants.js"),DEFAULT_API_BASE="https://aegiscloud.org";function disabledPresence(e){const n={enabled:!1,reason:String(e||"disabled"),beats:0,failures:0,lastError:null,lastSeenAt:null,intervalMs:null,install:null,app:null,running:!1};return{enabled:!1,reason:n.reason,install:null,beat:()=>Promise.resolve(!1),start:()=>!1,stop:()=>!1,state:()=>({...n})}}function isoOf(e){try{const n=Number(e);return Number.isFinite(n)?new Date(n).toISOString():null}catch{return null}}function createPresence(e={}){const n="string"==typeof e.apiKey?e.apiKey.trim():"";if(!n)return disabledPresence("no-api-key");if(!1===e.enabled)return disabledPresence("disabled");const t="function"==typeof e.log?e.log:()=>{};function r(e){if(!o.has(e)){o.add(e);try{t("debug",`presence: ${e}`)}catch{}}}const o=new Set,s="function"==typeof e.now?e.now:()=>Date.now(),i="function"==typeof e.setTimer?e.setTimer:setTimeout,a="function"==typeof e.clearTimer?e.clearTimer:clearTimeout;let l=saneToken(e.install,64);if(l||"function"!=typeof e.fingerprint){if(!l)try{l=saneToken(require("./device-fingerprint.js").machineFingerprint(),64)}catch{r("fingerprint")}}else try{l=saneToken(e.fingerprint(),64)}catch{r("fingerprint")}if(!l)return disabledPresence("no-install-identity");const u=saneToken(e.app,24).toLowerCase()||"other",c=saneToken(e.version,32),f=saneToken(e.platform,16).toLowerCase()||("undefined"!=typeof process&&process.platform?process.platform:""),_=String(e.baseUrl||"undefined"!=typeof process&&process.env&&process.env.AEGIS_API_BASE||DEFAULT_API_BASE).replace(/\/+$/,""),A=PRESENCE_PATH,E="function"==typeof e.fetchImpl?e.fetchImpl:"function"==typeof fetch?fetch:null;function M(e,n){try{return"function"==typeof e?e():e}catch{return n}}let p=b(e.intervalMs);const T={enabled:!0,reason:null,beats:0,failures:0,lastError:null,lastSeenAt:null,intervalMs:p,install:l,app:u,version:c||null,platform:f||null,running:!1};let S=null,m=null,d=0,N=!1;function b(e){const n=Number(e);return!Number.isFinite(n)||n<=0?DEFAULT_INTERVAL_MS:Math.min(MAX_INTERVAL_MS,Math.max(MIN_INTERVAL_MS,Math.floor(n)))}function y(){if(N)return Promise.resolve(!1);if(m)return m;if(!E)return r("no-fetch"),T.lastError="no fetch implementation",T.failures+=1,Promise.resolve(!1);const t=s();return m=(async()=>{let o=!1,P=null;try{const t="function"==typeof AbortController?new AbortController:null;if(t){const e=i((()=>{o=!0;try{t.abort()}catch{}}),REQUEST_TIMEOUT_MS);e&&"function"==typeof e.unref&&e.unref(),P=()=>a(e)}const r=await E(`${_}${A}`,{method:"POST",headers:{"Content-Type":"application/json","X-API-Key":n,"X-AEGIS-Version":saneToken(e.clientVersion,32)||c||""},body:JSON.stringify({app:u,version:c||void 0,platform:f||void 0,install:l,queue:saneCount(M(e.queueDepth,0)),autonomous:Boolean(M(e.autonomous,!1))}),...t?{signal:t.signal}:{}});if(P&&P(),!r||"number"!=typeof r.status||r.status<200||r.status>=300)return T.failures+=1,T.lastError=`http ${r&&r.status?r.status:"error"}`,d=I(d),!1;let S=null;try{S=await r.json()}catch{S=null}if(T.beats+=1,T.failures=0,T.lastError=null,T.lastSeenAt=isoOf(s()),d=0,S&&"object"==typeof S&&null!=S.beat_interval_s){const e=Number(S.beat_interval_s);Number.isFinite(e)&&e>0&&(p=b(1e3*e))}return T.intervalMs=p,!0}catch(e){return P&&P(),T.failures+=1,T.lastError=o?"timeout":String(e&&e.message||e||"error").slice(0,200),d=I(d),1===T.failures&&r("beat-failed"),!1}finally{m=null,T.running&&function(e){if(N||"function"!=typeof i)return;S&&a(S);const n=Math.max(1e3,Math.floor(Number(e)||p));S=i((()=>{S=null,y()}),n),S&&"function"==typeof S.unref&&S.unref()}(Math.max(0,p+d-(s()-t)))}})(),m}function I(e){const n=e>0?e*BACKOFF_FACTOR:Math.max(MIN_INTERVAL_MS,p);return Math.min(MAX_BACKOFF_MS,Math.floor(n))}return{enabled:!0,app:u,install:l,beat:y,start:function(){return!N&&(T.running=!0,y(),!0)},stop:function(){if(N=!0,T.running=!1,S){try{a(S)}catch{}S=null}return!0},pending:()=>m,state:()=>({...T,intervalMs:p})}}module.exports={createPresence:createPresence,disabledPresence:disabledPresence,PRESENCE_PATH:PRESENCE_PATH,DEFAULT_INTERVAL_MS:DEFAULT_INTERVAL_MS,MIN_INTERVAL_MS:MIN_INTERVAL_MS,MAX_INTERVAL_MS:MAX_INTERVAL_MS,MAX_BACKOFF_MS:MAX_BACKOFF_MS,REQUEST_TIMEOUT_MS:REQUEST_TIMEOUT_MS,APP_NAMES:APP_NAMES};
|