@parall/daemon 1.30.0 → 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,94 @@
1
+ import { verify } from "node:crypto";
2
+ /**
3
+ * Recursively sort all object keys at every depth for deterministic JSON output.
4
+ * Arrays preserve order; primitives pass through.
5
+ */
6
+ export function canonicalize(obj) {
7
+ if (Array.isArray(obj))
8
+ return obj.map(canonicalize);
9
+ if (obj !== null && typeof obj === "object") {
10
+ const sorted = {};
11
+ for (const k of Object.keys(obj).sort()) {
12
+ sorted[k] = canonicalize(obj[k]);
13
+ }
14
+ return sorted;
15
+ }
16
+ return obj;
17
+ }
18
+ /**
19
+ * Verify the Ed25519 signature of a remote manifest.
20
+ * Signs canonical JSON of all fields except `signature`.
21
+ */
22
+ export function verifyManifestSignature(manifest, publicKey) {
23
+ const { signature, ...rest } = manifest;
24
+ const canonical = JSON.stringify(canonicalize(rest));
25
+ try {
26
+ return verify(null, Buffer.from(canonical), publicKey, Buffer.from(signature, "base64"));
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ /**
33
+ * Compare two semver strings (including prerelease tags). Returns:
34
+ * -1 if a < b
35
+ * 0 if a == b
36
+ * 1 if a > b
37
+ * null if either is not valid semver
38
+ *
39
+ * Prerelease ordering follows SemVer 2.0: a version with prerelease has
40
+ * lower precedence than the same version without prerelease. Prerelease
41
+ * identifiers are compared lexicographically when both present.
42
+ */
43
+ export function semverCompare(a, b) {
44
+ const pa = parseSemver(a);
45
+ const pb = parseSemver(b);
46
+ if (!pa || !pb)
47
+ return null;
48
+ for (let i = 0; i < 3; i++) {
49
+ if (pa.nums[i] < pb.nums[i])
50
+ return -1;
51
+ if (pa.nums[i] > pb.nums[i])
52
+ return 1;
53
+ }
54
+ // Same major.minor.patch — compare prerelease:
55
+ // no prerelease > has prerelease (per SemVer §11)
56
+ if (!pa.pre && !pb.pre)
57
+ return 0;
58
+ if (!pa.pre)
59
+ return 1;
60
+ if (!pb.pre)
61
+ return -1;
62
+ // Both have prerelease: lexicographic comparison
63
+ if (pa.pre < pb.pre)
64
+ return -1;
65
+ if (pa.pre > pb.pre)
66
+ return 1;
67
+ return 0;
68
+ }
69
+ function parseSemver(v) {
70
+ // Split off prerelease at the first hyphen in the patch component.
71
+ // e.g. "0.0.0-staging.abc1234" → major=0, minor=0, patch="0", pre="staging.abc1234"
72
+ const parts = v.split(".");
73
+ if (parts.length < 3)
74
+ return null;
75
+ // Rejoin any dots beyond the first two (prerelease may contain dots)
76
+ const major = Number(parts[0]);
77
+ const minor = Number(parts[1]);
78
+ const rest = parts.slice(2).join(".");
79
+ // Split patch from prerelease: "0-staging.abc" → patch="0", pre="staging.abc"
80
+ const hyphen = rest.indexOf("-");
81
+ let patchStr;
82
+ let pre;
83
+ if (hyphen >= 0) {
84
+ patchStr = rest.slice(0, hyphen);
85
+ pre = rest.slice(hyphen + 1);
86
+ }
87
+ else {
88
+ patchStr = rest;
89
+ }
90
+ const patch = Number(patchStr);
91
+ if (isNaN(major) || isNaN(minor) || isNaN(patch))
92
+ return null;
93
+ return { nums: [major, minor, patch], pre };
94
+ }
@@ -0,0 +1,60 @@
1
+ import type { GatewayLogger } from "@parall/agent-core";
2
+ import { type LocalManifest } from "./updater-manifest.js";
3
+ export declare class DaemonUpdater {
4
+ private readonly bundleDir;
5
+ private readonly cdnBaseUrl;
6
+ private readonly log;
7
+ private readonly signingEnabled;
8
+ private updating;
9
+ private periodicTimer;
10
+ constructor(bundleDir: string, cdnBaseUrl: string, log: GatewayLogger, signingEnabled: boolean);
11
+ /**
12
+ * Start periodic update checks. Call once after supervisor bootstrap.
13
+ */
14
+ startPeriodicCheck(intervalMs: number): void;
15
+ stopPeriodicCheck(): void;
16
+ /**
17
+ * Check CDN for a newer version. Returns true if update was applied
18
+ * (caller should exit for service manager restart).
19
+ */
20
+ checkAndApply(targetVersion?: string): Promise<boolean>;
21
+ /**
22
+ * Handle WS-triggered update. Adds jitter for non-mandatory updates.
23
+ */
24
+ triggerUpdate(targetVersion: string, mandatory: boolean): Promise<boolean>;
25
+ /**
26
+ * Check if we need to roll back from a failed update.
27
+ * Call on every daemon startup, before bootstrap.
28
+ * Returns true if rollback was performed (caller should exit immediately).
29
+ */
30
+ checkRollback(): boolean;
31
+ /**
32
+ * Confirm the current version after successful health check.
33
+ * Clears pending state so rollback won't trigger.
34
+ */
35
+ confirmVersion(): void;
36
+ /**
37
+ * Get the current local manifest version (for heartbeat reporting).
38
+ */
39
+ getLocalVersion(): string | undefined;
40
+ /**
41
+ * Check CDN for available update without downloading.
42
+ * Returns remote version info for CLI --check display.
43
+ */
44
+ checkAvailable(): Promise<{
45
+ available: boolean;
46
+ currentVersion?: string;
47
+ remoteVersion?: string;
48
+ }>;
49
+ private doUpdate;
50
+ private atomicSwap;
51
+ private swapSymlink;
52
+ private pruneOldVersions;
53
+ loadLocalManifest(): LocalManifest | null;
54
+ private loadUpdateState;
55
+ private saveUpdateState;
56
+ private httpGet;
57
+ private downloadFile;
58
+ private cleanDir;
59
+ }
60
+ //# sourceMappingURL=updater.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"updater.d.ts","sourceRoot":"","sources":["../src/updater.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACxD,OAAO,EAEL,KAAK,aAAa,EAGnB,MAAM,uBAAuB,CAAC;AAoB/B,qBAAa,aAAa;IAKtB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,cAAc;IAPjC,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,aAAa,CAA+B;gBAGjC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,GAAG,EAAE,aAAa,EAClB,cAAc,EAAE,OAAO;IAK1C;;OAEG;IACH,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI;IAU5C,iBAAiB,IAAI,IAAI;IAOzB;;;OAGG;IACG,aAAa,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAa7D;;OAEG;IACG,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC;IAQhF;;;;OAIG;IACH,aAAa,IAAI,OAAO;IA6CxB;;;OAGG;IACH,cAAc,IAAI,IAAI;IAYtB;;OAEG;IACH,eAAe,IAAI,MAAM,GAAG,SAAS;IAIrC;;;OAGG;IACG,cAAc,IAAI,OAAO,CAAC;QAAE,SAAS,EAAE,OAAO,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;YAmB1F,QAAQ;IAqGtB,OAAO,CAAC,UAAU;IAmClB,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,gBAAgB;IAmBxB,iBAAiB,IAAI,aAAa,GAAG,IAAI;IAUzC,OAAO,CAAC,eAAe;IASvB,OAAO,CAAC,eAAe;IAQvB,OAAO,CAAC,OAAO;IA8Bf,OAAO,CAAC,YAAY;IAoCpB,OAAO,CAAC,QAAQ;CAKjB"}
@@ -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.30.0",
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.30.0",
33
- "@parall/sdk": "1.30.0",
34
- "@parall/claude-agent": "1.30.0",
35
- "@parall/codex-agent": "1.30.0",
36
- "@parall/openclaw-agent": "1.30.0"
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",