@parall/daemon 1.29.3 → 1.31.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.
@@ -0,0 +1,409 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as https from "node:https";
4
+ import * as http from "node:http";
5
+ import { createHash } from "node:crypto";
6
+ import { verifyManifestSignature, semverCompare, } from "./updater-manifest.js";
7
+ const UPDATE_EXIT_CODE = 42;
8
+ // Ed25519 public key for verifying daemon bundle manifests.
9
+ // The corresponding private key lives in GitHub Actions secrets (DAEMON_BUNDLE_SIGNING_KEY).
10
+ // Placeholder — replaced with the real key when the CI signing pipeline is set up.
11
+ const SIGNING_PUBLIC_KEY = process.env.PRLL_DAEMON_SIGNING_PUBLIC_KEY ?? "";
12
+ export class DaemonUpdater {
13
+ bundleDir;
14
+ cdnBaseUrl;
15
+ log;
16
+ signingEnabled;
17
+ updating = false;
18
+ periodicTimer = null;
19
+ constructor(bundleDir, cdnBaseUrl, log, signingEnabled) {
20
+ this.bundleDir = bundleDir;
21
+ this.cdnBaseUrl = cdnBaseUrl;
22
+ this.log = log;
23
+ this.signingEnabled = signingEnabled;
24
+ }
25
+ // --- Public API ---
26
+ /**
27
+ * Start periodic update checks. Call once after supervisor bootstrap.
28
+ */
29
+ startPeriodicCheck(intervalMs) {
30
+ if (intervalMs <= 0 || this.periodicTimer)
31
+ return;
32
+ this.periodicTimer = setInterval(() => {
33
+ this.checkAndApply().catch((err) => {
34
+ this.log.warn(`periodic update check failed: ${String(err)}`);
35
+ });
36
+ }, intervalMs);
37
+ this.periodicTimer.unref?.();
38
+ }
39
+ stopPeriodicCheck() {
40
+ if (this.periodicTimer) {
41
+ clearInterval(this.periodicTimer);
42
+ this.periodicTimer = null;
43
+ }
44
+ }
45
+ /**
46
+ * Check CDN for a newer version. Returns true if update was applied
47
+ * (caller should exit for service manager restart).
48
+ */
49
+ async checkAndApply(targetVersion) {
50
+ if (this.updating)
51
+ return false;
52
+ this.updating = true;
53
+ try {
54
+ return await this.doUpdate(targetVersion);
55
+ }
56
+ catch (err) {
57
+ this.log.warn(`update check failed: ${String(err)}`);
58
+ return false;
59
+ }
60
+ finally {
61
+ this.updating = false;
62
+ }
63
+ }
64
+ /**
65
+ * Handle WS-triggered update. Adds jitter for non-mandatory updates.
66
+ */
67
+ async triggerUpdate(targetVersion, mandatory) {
68
+ if (!mandatory) {
69
+ const jitter = Math.random() * 180_000;
70
+ await new Promise((r) => setTimeout(r, jitter));
71
+ }
72
+ return this.checkAndApply(targetVersion);
73
+ }
74
+ /**
75
+ * Check if we need to roll back from a failed update.
76
+ * Call on every daemon startup, before bootstrap.
77
+ * Returns true if rollback was performed (caller should exit immediately).
78
+ */
79
+ checkRollback() {
80
+ const state = this.loadUpdateState();
81
+ const current = this.loadLocalManifest();
82
+ if (!state || !current)
83
+ return false;
84
+ if (!state.pending_version || state.pending_version !== current.version)
85
+ return false;
86
+ state.boot_count = (state.boot_count ?? 0) + 1;
87
+ this.saveUpdateState(state);
88
+ if (state.boot_count < 3)
89
+ return false;
90
+ if (!state.previous_version)
91
+ return false;
92
+ const previousDir = path.join(this.bundleDir, "versions", state.previous_version);
93
+ if (!fs.existsSync(previousDir))
94
+ return false;
95
+ this.log.warn(`rollback: ${current.version} failed ${state.boot_count} boots, reverting to ${state.previous_version}`);
96
+ if (process.platform === "win32") {
97
+ // Windows: copy previous version files into current dir (no symlinks)
98
+ const currentDir = path.join(this.bundleDir, "current");
99
+ if (fs.existsSync(currentDir)) {
100
+ for (const file of fs.readdirSync(currentDir)) {
101
+ fs.unlinkSync(path.join(currentDir, file));
102
+ }
103
+ }
104
+ fs.mkdirSync(currentDir, { recursive: true });
105
+ for (const file of fs.readdirSync(previousDir)) {
106
+ fs.copyFileSync(path.join(previousDir, file), path.join(currentDir, file));
107
+ }
108
+ }
109
+ else {
110
+ this.swapSymlink(state.previous_version);
111
+ }
112
+ state.confirmed_version = state.previous_version;
113
+ state.pending_version = undefined;
114
+ state.boot_count = 0;
115
+ state.rollback_from = current.version;
116
+ state.rollback_at = new Date().toISOString();
117
+ this.saveUpdateState(state);
118
+ return true;
119
+ }
120
+ /**
121
+ * Confirm the current version after successful health check.
122
+ * Clears pending state so rollback won't trigger.
123
+ */
124
+ confirmVersion() {
125
+ const current = this.loadLocalManifest();
126
+ if (!current)
127
+ return;
128
+ const state = this.loadUpdateState() ?? {};
129
+ state.confirmed_version = current.version;
130
+ state.confirmed_at = new Date().toISOString();
131
+ state.pending_version = undefined;
132
+ state.boot_count = 0;
133
+ this.saveUpdateState(state);
134
+ this.log.info(`update: confirmed version ${current.version}`);
135
+ }
136
+ /**
137
+ * Get the current local manifest version (for heartbeat reporting).
138
+ */
139
+ getLocalVersion() {
140
+ return this.loadLocalManifest()?.version;
141
+ }
142
+ /**
143
+ * Check CDN for available update without downloading.
144
+ * Returns remote version info for CLI --check display.
145
+ */
146
+ async checkAvailable() {
147
+ const local = this.loadLocalManifest();
148
+ const manifestUrl = `${this.cdnBaseUrl}/latest/manifest.json`;
149
+ try {
150
+ const remoteJson = await this.httpGet(manifestUrl);
151
+ const remote = JSON.parse(remoteJson);
152
+ const current = local?.version;
153
+ if (current) {
154
+ const cmp = semverCompare(remote.version, current);
155
+ return { available: cmp !== null && cmp > 0, currentVersion: current, remoteVersion: remote.version };
156
+ }
157
+ return { available: true, remoteVersion: remote.version };
158
+ }
159
+ catch {
160
+ return { available: false, currentVersion: local?.version };
161
+ }
162
+ }
163
+ // --- Internal ---
164
+ async doUpdate(targetVersion) {
165
+ const local = this.loadLocalManifest();
166
+ const manifestUrl = targetVersion
167
+ ? `${this.cdnBaseUrl}/${targetVersion}/manifest.json`
168
+ : `${this.cdnBaseUrl}/latest/manifest.json`;
169
+ let remoteJson;
170
+ try {
171
+ remoteJson = await this.httpGet(manifestUrl);
172
+ }
173
+ catch (err) {
174
+ this.log.warn(`update: failed to fetch manifest from ${manifestUrl}: ${String(err)}`);
175
+ return false;
176
+ }
177
+ let remote;
178
+ try {
179
+ remote = JSON.parse(remoteJson);
180
+ }
181
+ catch {
182
+ this.log.warn("update: failed to parse remote manifest");
183
+ return false;
184
+ }
185
+ // Reject same or older versions (downgrade protection)
186
+ if (local) {
187
+ const cmp = semverCompare(remote.version, local.version);
188
+ if (cmp === null || cmp <= 0)
189
+ return false;
190
+ }
191
+ // Skip versions we already rolled back from
192
+ const state = this.loadUpdateState();
193
+ if (state?.rollback_from === remote.version) {
194
+ this.log.info(`update: skipping ${remote.version} (previously rolled back)`);
195
+ return false;
196
+ }
197
+ // Verify Ed25519 signature (fail-closed: reject when key is missing)
198
+ if (this.signingEnabled) {
199
+ if (!SIGNING_PUBLIC_KEY) {
200
+ this.log.error("update: signing enabled but PRLL_DAEMON_SIGNING_PUBLIC_KEY is empty — rejecting update");
201
+ return false;
202
+ }
203
+ if (!verifyManifestSignature(remote, SIGNING_PUBLIC_KEY)) {
204
+ this.log.error(`update: signature verification failed for ${remote.version}`);
205
+ return false;
206
+ }
207
+ }
208
+ // Download all files to staging directory
209
+ const stagingDir = path.join(this.bundleDir, "staging");
210
+ this.cleanDir(stagingDir);
211
+ fs.mkdirSync(stagingDir, { recursive: true });
212
+ for (const [filename, meta] of Object.entries(remote.files)) {
213
+ const filePath = path.join(stagingDir, filename);
214
+ const fileUrl = `${this.cdnBaseUrl}/${remote.version}/${filename}`;
215
+ try {
216
+ await this.downloadFile(fileUrl, filePath);
217
+ }
218
+ catch (err) {
219
+ this.log.error(`update: download failed for ${filename}: ${String(err)}`);
220
+ this.cleanDir(stagingDir);
221
+ return false;
222
+ }
223
+ // Verify sha256
224
+ const content = fs.readFileSync(filePath);
225
+ const hash = createHash("sha256").update(content).digest("hex");
226
+ if (hash !== meta.sha256) {
227
+ this.log.error(`update: sha256 mismatch for ${filename} (expected ${meta.sha256}, got ${hash})`);
228
+ this.cleanDir(stagingDir);
229
+ return false;
230
+ }
231
+ // Verify size
232
+ if (content.length !== meta.size) {
233
+ this.log.error(`update: size mismatch for ${filename} (expected ${meta.size}, got ${content.length})`);
234
+ this.cleanDir(stagingDir);
235
+ return false;
236
+ }
237
+ }
238
+ // Write manifest to staging
239
+ fs.writeFileSync(path.join(stagingDir, "manifest.json"), JSON.stringify(remote, null, 2));
240
+ // Atomic swap
241
+ this.atomicSwap(stagingDir, remote.version);
242
+ // Record pending update state
243
+ const newState = {
244
+ confirmed_version: local?.version ?? state?.confirmed_version,
245
+ previous_version: local?.version,
246
+ pending_version: remote.version,
247
+ pending_at: new Date().toISOString(),
248
+ boot_count: 0,
249
+ };
250
+ this.saveUpdateState(newState);
251
+ this.log.info(`update: ${local?.version ?? "unknown"} → ${remote.version} applied, restarting`);
252
+ return true;
253
+ }
254
+ atomicSwap(stagingDir, newVersion) {
255
+ const versionsDir = path.join(this.bundleDir, "versions");
256
+ const targetDir = path.join(versionsDir, newVersion);
257
+ const currentLink = path.join(this.bundleDir, "current");
258
+ fs.mkdirSync(versionsDir, { recursive: true });
259
+ // If target version dir already exists (partial previous attempt), remove it
260
+ if (fs.existsSync(targetDir)) {
261
+ fs.rmSync(targetDir, { recursive: true });
262
+ }
263
+ fs.renameSync(stagingDir, targetDir);
264
+ if (process.platform === "win32") {
265
+ // Windows: JS files aren't locked, copy directly.
266
+ // Clean old files first to avoid stale artifacts from previous versions.
267
+ const currentDir = currentLink;
268
+ if (fs.existsSync(currentDir)) {
269
+ for (const file of fs.readdirSync(currentDir)) {
270
+ fs.unlinkSync(path.join(currentDir, file));
271
+ }
272
+ }
273
+ fs.mkdirSync(currentDir, { recursive: true });
274
+ for (const file of fs.readdirSync(targetDir)) {
275
+ fs.copyFileSync(path.join(targetDir, file), path.join(currentDir, file));
276
+ }
277
+ }
278
+ else {
279
+ // POSIX: atomic symlink swap
280
+ this.swapSymlink(newVersion);
281
+ }
282
+ // Prune old versions (keep current + previous)
283
+ this.pruneOldVersions(versionsDir, newVersion);
284
+ }
285
+ swapSymlink(version) {
286
+ const currentLink = path.join(this.bundleDir, "current");
287
+ const tmpLink = `${currentLink}.new`;
288
+ try {
289
+ fs.unlinkSync(tmpLink);
290
+ }
291
+ catch { }
292
+ fs.symlinkSync(`versions/${version}`, tmpLink);
293
+ fs.renameSync(tmpLink, currentLink);
294
+ }
295
+ pruneOldVersions(versionsDir, currentVersion) {
296
+ const state = this.loadUpdateState();
297
+ const keep = new Set([currentVersion]);
298
+ if (state?.previous_version)
299
+ keep.add(state.previous_version);
300
+ if (state?.confirmed_version)
301
+ keep.add(state.confirmed_version);
302
+ try {
303
+ for (const entry of fs.readdirSync(versionsDir)) {
304
+ if (!keep.has(entry)) {
305
+ fs.rmSync(path.join(versionsDir, entry), { recursive: true });
306
+ }
307
+ }
308
+ }
309
+ catch {
310
+ // best-effort cleanup
311
+ }
312
+ }
313
+ // --- Manifest & State I/O ---
314
+ loadLocalManifest() {
315
+ const currentDir = path.join(this.bundleDir, "current");
316
+ const manifestPath = path.join(currentDir, "manifest.json");
317
+ try {
318
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
319
+ }
320
+ catch {
321
+ return null;
322
+ }
323
+ }
324
+ loadUpdateState() {
325
+ const statePath = path.join(this.bundleDir, "update-state.json");
326
+ try {
327
+ return JSON.parse(fs.readFileSync(statePath, "utf-8"));
328
+ }
329
+ catch {
330
+ return null;
331
+ }
332
+ }
333
+ saveUpdateState(state) {
334
+ const statePath = path.join(this.bundleDir, "update-state.json");
335
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
336
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
337
+ }
338
+ // --- HTTP helpers ---
339
+ httpGet(url, maxRedirects = 5) {
340
+ return new Promise((resolve, reject) => {
341
+ const mod = url.startsWith("https") ? https : http;
342
+ const req = mod.get(url, (res) => {
343
+ if (res.statusCode === 301 || res.statusCode === 302) {
344
+ if (res.headers.location && maxRedirects > 0) {
345
+ this.httpGet(res.headers.location, maxRedirects - 1).then(resolve, reject);
346
+ return;
347
+ }
348
+ res.resume();
349
+ reject(new Error(`too many redirects or missing location for ${url}`));
350
+ return;
351
+ }
352
+ if (res.statusCode !== 200) {
353
+ res.resume();
354
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
355
+ return;
356
+ }
357
+ const chunks = [];
358
+ res.on("data", (chunk) => chunks.push(chunk));
359
+ res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
360
+ res.on("error", reject);
361
+ });
362
+ req.on("error", reject);
363
+ req.setTimeout(30_000, () => {
364
+ req.destroy(new Error(`timeout fetching ${url}`));
365
+ });
366
+ });
367
+ }
368
+ downloadFile(url, dest, maxRedirects = 5) {
369
+ return new Promise((resolve, reject) => {
370
+ const mod = url.startsWith("https") ? https : http;
371
+ const req = mod.get(url, (res) => {
372
+ if (res.statusCode === 301 || res.statusCode === 302) {
373
+ if (res.headers.location && maxRedirects > 0) {
374
+ this.downloadFile(res.headers.location, dest, maxRedirects - 1).then(resolve, reject);
375
+ return;
376
+ }
377
+ res.resume();
378
+ reject(new Error(`too many redirects or missing location for ${url}`));
379
+ return;
380
+ }
381
+ if (res.statusCode !== 200) {
382
+ res.resume();
383
+ reject(new Error(`HTTP ${res.statusCode} for ${url}`));
384
+ return;
385
+ }
386
+ const file = fs.createWriteStream(dest);
387
+ res.pipe(file);
388
+ file.on("finish", () => {
389
+ file.close();
390
+ resolve();
391
+ });
392
+ file.on("error", (err) => {
393
+ fs.unlinkSync(dest);
394
+ reject(err);
395
+ });
396
+ });
397
+ req.on("error", reject);
398
+ req.setTimeout(120_000, () => {
399
+ req.destroy(new Error(`timeout downloading ${url}`));
400
+ });
401
+ });
402
+ }
403
+ cleanDir(dir) {
404
+ try {
405
+ fs.rmSync(dir, { recursive: true });
406
+ }
407
+ catch { }
408
+ }
409
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/daemon",
3
- "version": "1.29.3",
3
+ "version": "1.31.0",
4
4
  "description": "Parall local agent runtime — daemon supervisor + bridge runtimes, bundled as standalone JS files",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -29,11 +29,11 @@
29
29
  "dist"
30
30
  ],
31
31
  "dependencies": {
32
- "@parall/agent-core": "1.29.3",
33
- "@parall/sdk": "1.29.3",
34
- "@parall/claude-agent": "1.29.3",
35
- "@parall/codex-agent": "1.29.3",
36
- "@parall/openclaw-agent": "1.29.3"
32
+ "@parall/agent-core": "1.31.0",
33
+ "@parall/sdk": "1.31.0",
34
+ "@parall/claude-agent": "1.31.0",
35
+ "@parall/codex-agent": "1.31.0",
36
+ "@parall/openclaw-agent": "1.31.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/node": "^22.0.0",