podwatch 1.1.8 → 1.2.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/dist/updater.js CHANGED
@@ -4,17 +4,18 @@
4
4
  *
5
5
  * On plugin startup (called from register()), schedules a non-blocking update
6
6
  * check after a 30-second delay. If a new version is available on npm (or the
7
- * Podwatch dashboard fallback), it downloads via `npm pack`, extracts to the
8
- * extensions directory, writes a restart sentinel, and triggers a gateway
9
- * restart via the OS service manager (systemd / launchctl).
7
+ * Podwatch dashboard fallback), it downloads the tarball via fetch(), verifies
8
+ * integrity, extracts to the extensions directory, and writes a restart sentinel.
10
9
  *
11
10
  * Safety:
12
11
  * - 30s startup delay (don't slow boot)
13
12
  * - 24-hour cooldown between checks (cached in a local file)
14
13
  * - 5s timeout on all HTTP requests
15
- * - 120s timeout on npm pack command
16
14
  * - All errors caught — never bricks the running plugin
17
- * - Logs all activity for debugging
15
+ * - Rollback: backs up old dist/ before replacing, restores on extraction failure
16
+ * - Plugin NEVER restarts its host — only writes restart sentinel
17
+ * - Auto-update defaults to ON (set autoUpdate: false to disable)
18
+ * - No handleUrgentUpdate — removed as remote code execution vector
18
19
  */
19
20
  var __importDefault = (this && this.__importDefault) || function (mod) {
20
21
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -22,9 +23,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
22
23
  Object.defineProperty(exports, "__esModule", { value: true });
23
24
  exports.AUTO_UPDATE_CACHE_FILE = void 0;
24
25
  exports.resolveStateDir = resolveStateDir;
25
- exports.resolveGatewaySystemdServiceName = resolveGatewaySystemdServiceName;
26
- exports.resolveGatewayLaunchAgentLabel = resolveGatewayLaunchAgentLabel;
27
- exports.normalizeSystemdUnit = normalizeSystemdUnit;
26
+ exports.getInstalledVersion = getInstalledVersion;
28
27
  exports.compareVersions = compareVersions;
29
28
  exports.shouldCheckForUpdate = shouldCheckForUpdate;
30
29
  exports.writeCacheTimestamp = writeCacheTimestamp;
@@ -32,26 +31,33 @@ exports.checkForUpdate = checkForUpdate;
32
31
  exports.writeRestartSentinel = writeRestartSentinel;
33
32
  exports.parseSriHash = parseSriHash;
34
33
  exports.verifyTarballIntegrity = verifyTarballIntegrity;
34
+ exports.backupDist = backupDist;
35
+ exports.restoreFromBackup = restoreFromBackup;
36
+ exports.cleanupBackup = cleanupBackup;
35
37
  exports.downloadTarball = downloadTarball;
36
38
  exports.installFromTarball = installFromTarball;
37
39
  exports.executeUpdate = executeUpdate;
40
+ exports.verifyNewDist = verifyNewDist;
38
41
  exports.waitForIdleWindow = waitForIdleWindow;
39
42
  exports.triggerGatewayRestart = triggerGatewayRestart;
40
- exports.handleUrgentUpdate = handleUrgentUpdate;
43
+ exports.acquireUpdateLock = acquireUpdateLock;
44
+ exports.releaseUpdateLock = releaseUpdateLock;
41
45
  exports.scheduleUpdateCheck = scheduleUpdateCheck;
42
46
  exports.runUpdateCheck = runUpdateCheck;
43
47
  const node_child_process_1 = require("node:child_process");
48
+ const transmitter_js_1 = require("./transmitter.js");
49
+ const activity_tracker_js_1 = require("./activity-tracker.js");
44
50
  const node_crypto_1 = __importDefault(require("node:crypto"));
45
51
  const node_fs_1 = __importDefault(require("node:fs"));
46
52
  const node_path_1 = __importDefault(require("node:path"));
47
53
  const node_os_1 = __importDefault(require("node:os"));
48
- const activity_tracker_js_1 = require("./activity-tracker.js");
49
54
  // ---------------------------------------------------------------------------
50
55
  // Constants
51
56
  // ---------------------------------------------------------------------------
52
57
  const NPM_REGISTRY_URL = "https://registry.npmjs.org/podwatch/latest";
58
+ const NPM_TARBALL_URL_PREFIX = "https://registry.npmjs.org/podwatch/-/podwatch-";
53
59
  const CHECK_TIMEOUT_MS = 5_000;
54
- const NPM_PACK_TIMEOUT_MS = 120_000;
60
+ const DOWNLOAD_TIMEOUT_MS = 60_000;
55
61
  const SPAWN_TIMEOUT_MS = 15_000;
56
62
  const STARTUP_DELAY_MS = 30_000;
57
63
  const COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -61,6 +67,8 @@ const DEFER_INTERVAL_MS = 5 * 60 * 1000; // Re-check every 5 minutes
61
67
  const MAX_DEFER_MS = 2 * 60 * 60 * 1000; // Max 2 hours of deferral
62
68
  const CACHE_DIR = node_path_1.default.join(process.env.HOME ?? process.env.USERPROFILE ?? "/tmp", ".openclaw", "extensions", "podwatch");
63
69
  exports.AUTO_UPDATE_CACHE_FILE = node_path_1.default.join(CACHE_DIR, ".last-update-check");
70
+ const UPDATE_LOCK_FILE = node_path_1.default.join(CACHE_DIR, ".update-lock");
71
+ const UPDATE_LOCK_STALE_MS = 30 * 60 * 1000; // 30 minutes — stale lock threshold
64
72
  // ---------------------------------------------------------------------------
65
73
  // State dir resolution (mirrors OpenClaw's resolveStateDir)
66
74
  // ---------------------------------------------------------------------------
@@ -76,48 +84,23 @@ function resolveStateDir() {
76
84
  return node_path_1.default.join(home, ".openclaw");
77
85
  }
78
86
  // ---------------------------------------------------------------------------
79
- // Service name resolution (mirrors OpenClaw's constants)
87
+ // Version comparison
80
88
  // ---------------------------------------------------------------------------
81
89
  /**
82
- * Normalize a gateway profile string. Returns null for empty/default.
90
+ * Read the installed plugin version from disk (extensions dir package.json).
91
+ * Returns null if the file can't be read.
83
92
  */
84
- function normalizeGatewayProfile(profile) {
85
- const trimmed = profile?.trim();
86
- if (!trimmed || trimmed.toLowerCase() === "default")
93
+ function getInstalledVersion() {
94
+ try {
95
+ const extensionsDir = node_path_1.default.join(process.env.HOME ?? process.env.USERPROFILE ?? "/tmp", ".openclaw", "extensions", "podwatch");
96
+ const pkgPath = node_path_1.default.join(extensionsDir, "package.json");
97
+ const pkg = JSON.parse(node_fs_1.default.readFileSync(pkgPath, "utf8"));
98
+ return typeof pkg.version === "string" ? pkg.version : null;
99
+ }
100
+ catch {
87
101
  return null;
88
- return trimmed;
89
- }
90
- /**
91
- * Resolve the systemd service name for the gateway.
92
- */
93
- function resolveGatewaySystemdServiceName(profile) {
94
- const normalized = normalizeGatewayProfile(profile);
95
- const suffix = normalized ? `-${normalized}` : "";
96
- if (!suffix)
97
- return "openclaw-gateway";
98
- return `openclaw-gateway${suffix}`;
99
- }
100
- /**
101
- * Resolve the launchd label for the gateway.
102
- */
103
- function resolveGatewayLaunchAgentLabel(profile) {
104
- const normalized = normalizeGatewayProfile(profile);
105
- if (!normalized)
106
- return "ai.openclaw.gateway";
107
- return `ai.openclaw.${normalized}`;
108
- }
109
- /**
110
- * Normalize a systemd unit name. Appends .service if missing.
111
- */
112
- function normalizeSystemdUnit(raw, profile) {
113
- const unit = raw?.trim();
114
- if (!unit)
115
- return `${resolveGatewaySystemdServiceName(profile)}.service`;
116
- return unit.endsWith(".service") ? unit : `${unit}.service`;
102
+ }
117
103
  }
118
- // ---------------------------------------------------------------------------
119
- // Version comparison
120
- // ---------------------------------------------------------------------------
121
104
  /**
122
105
  * Compare two semver-like version strings.
123
106
  * Returns positive if remote > local, negative if local > remote, 0 if equal.
@@ -186,11 +169,13 @@ async function checkForUpdate(currentVersion, endpoint) {
186
169
  const cmp = compareVersions(currentVersion, data.version);
187
170
  // npm registry provides integrity as SRI string at dist.integrity
188
171
  const integrity = typeof data.dist?.integrity === "string" ? data.dist.integrity : undefined;
172
+ const tarballUrl = typeof data.dist?.tarball === "string" ? data.dist.tarball : undefined;
189
173
  return {
190
174
  available: cmp > 0,
191
175
  remoteVersion: data.version,
192
176
  source: "npm",
193
177
  integrityHash: integrity,
178
+ tarballUrl,
194
179
  };
195
180
  }
196
181
  }
@@ -293,44 +278,93 @@ function verifyTarballIntegrity(tarballPath, expectedHash) {
293
278
  };
294
279
  }
295
280
  }
281
+ // ---------------------------------------------------------------------------
282
+ // Rollback helpers
283
+ // ---------------------------------------------------------------------------
284
+ /**
285
+ * Back up the existing dist/ directory before replacing it.
286
+ * Returns the backup path, or null if no dist/ exists.
287
+ */
288
+ function backupDist(extensionsDir) {
289
+ const distDir = node_path_1.default.join(extensionsDir, "dist");
290
+ if (!node_fs_1.default.existsSync(distDir))
291
+ return null;
292
+ const backupDir = node_path_1.default.join(extensionsDir, "dist.backup");
293
+ try {
294
+ // Remove stale backup if it exists
295
+ if (node_fs_1.default.existsSync(backupDir)) {
296
+ node_fs_1.default.rmSync(backupDir, { recursive: true, force: true });
297
+ }
298
+ node_fs_1.default.renameSync(distDir, backupDir);
299
+ return backupDir;
300
+ }
301
+ catch (err) {
302
+ console.warn("[podwatch/updater] Failed to backup dist/:", err);
303
+ return null;
304
+ }
305
+ }
306
+ /**
307
+ * Restore dist/ from backup after a failed extraction.
308
+ */
309
+ function restoreFromBackup(extensionsDir, backupDir) {
310
+ try {
311
+ const distDir = node_path_1.default.join(extensionsDir, "dist");
312
+ // Remove the failed extraction
313
+ if (node_fs_1.default.existsSync(distDir)) {
314
+ node_fs_1.default.rmSync(distDir, { recursive: true, force: true });
315
+ }
316
+ node_fs_1.default.renameSync(backupDir, distDir);
317
+ return true;
318
+ }
319
+ catch (err) {
320
+ console.error("[podwatch/updater] Failed to restore dist/ from backup:", err);
321
+ return false;
322
+ }
323
+ }
296
324
  /**
297
- * Download the latest podwatch tarball via npm pack.
298
- * Returns the tarball path on success for integrity verification.
325
+ * Clean up backup after successful extraction.
299
326
  */
300
- function downloadTarball() {
327
+ function cleanupBackup(backupDir) {
328
+ try {
329
+ if (node_fs_1.default.existsSync(backupDir)) {
330
+ node_fs_1.default.rmSync(backupDir, { recursive: true, force: true });
331
+ }
332
+ }
333
+ catch {
334
+ // Best-effort cleanup
335
+ }
336
+ }
337
+ /**
338
+ * Download the podwatch tarball via fetch() from the npm registry URL.
339
+ * No shell commands needed — pure HTTP download.
340
+ */
341
+ async function downloadTarball(version, tarballUrl) {
301
342
  let tmpDir = null;
302
343
  try {
303
- // 1. Create temp dir for npm pack
344
+ // 1. Create temp dir
304
345
  tmpDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "podwatch-update-"));
305
- // 2. npm pack podwatch (downloads latest from registry)
306
- const packResult = (0, node_child_process_1.spawnSync)("npm", ["pack", "podwatch", "--pack-destination", tmpDir], {
307
- encoding: "utf8",
308
- timeout: NPM_PACK_TIMEOUT_MS,
309
- cwd: tmpDir,
346
+ // 2. Determine download URL
347
+ const url = tarballUrl ?? `${NPM_TARBALL_URL_PREFIX}${version ?? "latest"}.tgz`;
348
+ // 3. Fetch the tarball
349
+ const response = await fetch(url, {
350
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
310
351
  });
311
- if (packResult.error || packResult.status !== 0) {
352
+ if (!response.ok) {
312
353
  return {
313
354
  success: false,
314
- stdout: packResult.stdout,
315
- stderr: packResult.stderr,
316
- error: packResult.error?.message ?? `npm pack exited with status ${packResult.status}`,
317
- tmpDir,
318
- };
319
- }
320
- // Find the tarball filename from stdout (npm pack prints the filename)
321
- const tarballName = packResult.stdout.trim().split("\n").pop()?.trim();
322
- if (!tarballName) {
323
- return {
324
- success: false,
325
- stdout: packResult.stdout,
326
- error: "npm pack did not output a tarball filename",
355
+ error: `Tarball download failed: HTTP ${response.status}`,
327
356
  tmpDir,
328
357
  };
329
358
  }
359
+ const arrayBuffer = await response.arrayBuffer();
360
+ const buffer = Buffer.from(arrayBuffer);
361
+ // 4. Write to temp file
362
+ const tarballName = `podwatch-${version ?? "latest"}.tgz`;
330
363
  const tarballPath = node_path_1.default.join(tmpDir, tarballName);
364
+ node_fs_1.default.writeFileSync(tarballPath, buffer);
331
365
  return {
332
366
  success: true,
333
- stdout: `Downloaded ${tarballName}`,
367
+ stdout: `Downloaded ${tarballName} (${buffer.length} bytes)`,
334
368
  tarballPath,
335
369
  tmpDir,
336
370
  };
@@ -345,23 +379,27 @@ function downloadTarball() {
345
379
  }
346
380
  /**
347
381
  * Install from a previously downloaded tarball by extracting to the extensions dir.
382
+ * Includes rollback: backs up old dist/ before extraction, restores on failure.
348
383
  */
349
384
  function installFromTarball(tarballPath) {
350
385
  const extensionsDir = node_path_1.default.join(process.env.HOME ?? process.env.USERPROFILE ?? "/tmp", ".openclaw", "extensions", "podwatch");
386
+ let backupDir = null;
351
387
  try {
352
- // 1. Clean old dist before extracting
353
- const distDir = node_path_1.default.join(extensionsDir, "dist");
354
- if (node_fs_1.default.existsSync(distDir)) {
355
- node_fs_1.default.rmSync(distDir, { recursive: true, force: true });
356
- }
357
- // 2. Ensure extensions dir exists
388
+ // 1. Ensure extensions dir exists
358
389
  node_fs_1.default.mkdirSync(extensionsDir, { recursive: true });
390
+ // 2. Backup existing dist/ before replacing
391
+ backupDir = backupDist(extensionsDir);
359
392
  // 3. Extract tarball — npm pack creates tarballs with a "package/" prefix
360
393
  const extractResult = (0, node_child_process_1.spawnSync)("tar", ["xzf", tarballPath, "--strip-components=1", "-C", extensionsDir], {
361
394
  encoding: "utf8",
362
395
  timeout: 30_000,
363
396
  });
364
397
  if (extractResult.error || extractResult.status !== 0) {
398
+ // Rollback on extraction failure
399
+ if (backupDir) {
400
+ restoreFromBackup(extensionsDir, backupDir);
401
+ console.warn("[podwatch/updater] Extraction failed — rolled back to previous version.");
402
+ }
365
403
  return {
366
404
  success: false,
367
405
  stdout: extractResult.stdout,
@@ -369,6 +407,9 @@ function installFromTarball(tarballPath) {
369
407
  error: extractResult.error?.message ?? `tar extract exited with status ${extractResult.status}`,
370
408
  };
371
409
  }
410
+ // 4. Leave backup in place — caller (runUpdateCheck) cleans up after
411
+ // verifyNewDist() confirms the new code works. Stale backups from
412
+ // prior runs are handled by backupDist() which removes them first.
372
413
  const tarballName = node_path_1.default.basename(tarballPath);
373
414
  return {
374
415
  success: true,
@@ -376,6 +417,11 @@ function installFromTarball(tarballPath) {
376
417
  };
377
418
  }
378
419
  catch (err) {
420
+ // Rollback on any error
421
+ if (backupDir) {
422
+ restoreFromBackup(extensionsDir, backupDir);
423
+ console.warn("[podwatch/updater] Install failed — rolled back to previous version.");
424
+ }
379
425
  return {
380
426
  success: false,
381
427
  error: err instanceof Error ? err.message : String(err),
@@ -383,39 +429,92 @@ function installFromTarball(tarballPath) {
383
429
  }
384
430
  }
385
431
  /**
386
- * Download and install the latest podwatch plugin via npm pack + extract.
432
+ * Download and install the latest podwatch plugin via fetch + extract.
387
433
  * Legacy single-step function — delegates to downloadTarball + installFromTarball.
388
- *
389
- * Steps:
390
- * 1. `npm pack podwatch` in a temp dir to get the tarball
391
- * 2. Clean old dist in the extensions directory
392
- * 3. Extract tarball contents to ~/.openclaw/extensions/podwatch/
393
434
  */
394
435
  function executeUpdate() {
395
- const download = downloadTarball();
436
+ // executeUpdate is sync for backward compat — use spawnSync-based fallback
437
+ const extensionsDir = node_path_1.default.join(process.env.HOME ?? process.env.USERPROFILE ?? "/tmp", ".openclaw", "extensions", "podwatch");
438
+ let tmpDir = null;
396
439
  try {
397
- if (!download.success || !download.tarballPath) {
440
+ tmpDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), "podwatch-update-"));
441
+ // Use npx npm pack as sync fallback (downloadTarball is async)
442
+ const packResult = (0, node_child_process_1.spawnSync)("npx", ["-y", "npm", "pack", "podwatch", "--pack-destination", tmpDir], {
443
+ encoding: "utf8",
444
+ timeout: 120_000,
445
+ cwd: tmpDir,
446
+ });
447
+ if (packResult.error || packResult.status !== 0) {
448
+ return {
449
+ success: false,
450
+ stdout: packResult.stdout,
451
+ stderr: packResult.stderr,
452
+ error: packResult.error?.message ?? `npx npm pack exited with status ${packResult.status}`,
453
+ };
454
+ }
455
+ const tarballName = packResult.stdout.trim().split("\n").pop()?.trim();
456
+ if (!tarballName) {
398
457
  return {
399
458
  success: false,
400
- stdout: download.stdout,
401
- stderr: download.stderr,
402
- error: download.error ?? "Download failed",
459
+ stdout: packResult.stdout,
460
+ error: "npm pack did not output a tarball filename",
403
461
  };
404
462
  }
405
- return installFromTarball(download.tarballPath);
463
+ const tarballPath = node_path_1.default.join(tmpDir, tarballName);
464
+ return installFromTarball(tarballPath);
465
+ }
466
+ catch (err) {
467
+ return {
468
+ success: false,
469
+ error: err instanceof Error ? err.message : String(err),
470
+ };
406
471
  }
407
472
  finally {
408
- // Cleanup temp dir
409
- if (download.tmpDir) {
473
+ if (tmpDir) {
410
474
  try {
411
- node_fs_1.default.rmSync(download.tmpDir, { recursive: true, force: true });
412
- }
413
- catch {
414
- // Best-effort cleanup
475
+ node_fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
415
476
  }
477
+ catch { /* best-effort */ }
416
478
  }
417
479
  }
418
480
  }
481
+ /**
482
+ * Verify that the newly installed dist/index.js is loadable.
483
+ * Tries to require() the entry point — if it throws, the new code is broken.
484
+ * Returns a detailed result indicating success or failure reason.
485
+ */
486
+ function verifyNewDist(extensionsDir) {
487
+ const entryPoint = node_path_1.default.join(extensionsDir, "dist", "index.js");
488
+ try {
489
+ if (!node_fs_1.default.existsSync(entryPoint)) {
490
+ return { ok: false, error: "dist/index.js not found" };
491
+ }
492
+ // Use a child process to verify — don't pollute our own module cache
493
+ // Timeout set to 15s to allow for reasonable module initialization
494
+ const result = (0, node_child_process_1.spawnSync)(process.execPath, ["-e", `try { require(${JSON.stringify(entryPoint)}); process.exit(0); } catch(e) { console.error(e.message); process.exit(1); }`], { encoding: "utf8", timeout: 15_000 });
495
+ // Check for crash signals (SIGSEGV, SIGABRT, etc.)
496
+ if (result.signal) {
497
+ return { ok: false, signal: result.signal, error: `Process killed by signal: ${result.signal}` };
498
+ }
499
+ // Check for timeout
500
+ if (result.error?.message?.includes("ETIMEDOUT") || result.error?.message?.includes("timed out")) {
501
+ return { ok: false, timedOut: true, error: "Module load timed out after 15s" };
502
+ }
503
+ // Check for spawn errors
504
+ if (result.error) {
505
+ return { ok: false, error: result.error.message };
506
+ }
507
+ // Check exit code
508
+ if (result.status !== 0) {
509
+ const stderr = result.stderr?.trim();
510
+ return { ok: false, error: stderr || `Exit code: ${result.status}` };
511
+ }
512
+ return { ok: true };
513
+ }
514
+ catch (err) {
515
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
516
+ }
517
+ }
419
518
  // ---------------------------------------------------------------------------
420
519
  // Graceful idle window — defer restart until agent is inactive
421
520
  // ---------------------------------------------------------------------------
@@ -449,50 +548,60 @@ async function waitForIdleWindow(logger) {
449
548
  return true;
450
549
  }
451
550
  /**
452
- * Trigger a gateway restart using the OS service manager directly.
453
- * Mirrors OpenClaw's triggerOpenClawRestart logic.
551
+ * Resolve the systemd service name for the gateway.
552
+ */
553
+ function resolveGatewayServiceName(profile) {
554
+ const trimmed = profile?.trim();
555
+ const normalized = (!trimmed || trimmed.toLowerCase() === "default") ? null : trimmed;
556
+ const suffix = normalized ? `-${normalized}` : "";
557
+ return `openclaw-gateway${suffix}.service`;
558
+ }
559
+ /**
560
+ * Trigger a gateway restart using the OS service manager.
561
+ *
562
+ * Safety improvements over original:
563
+ * - Only called AFTER verifyNewDist() confirms the new code loads
564
+ * - Only called AFTER integrity verification passed
565
+ * - Only called AFTER rollback backup is in place
566
+ * - Single attempt — if restart fails, logs error and moves on (no retry loop)
454
567
  *
455
- * Linux: systemctl --user restart <unit> (user first, then system fallback)
568
+ * Linux: systemctl --user restart <unit>
456
569
  * macOS: launchctl kickstart -k gui/<uid>/<label>
457
- * Fallback: log a manual restart message
458
570
  */
459
571
  function triggerGatewayRestart(logger) {
460
572
  const tried = [];
461
573
  if (process.platform === "linux") {
462
- const unit = normalizeSystemdUnit(process.env.OPENCLAW_SYSTEMD_UNIT, process.env.OPENCLAW_PROFILE);
463
- // Try user-level systemctl first
464
- const userArgs = ["--user", "restart", unit];
465
- tried.push(`systemctl ${userArgs.join(" ")}`);
466
- const userRestart = (0, node_child_process_1.spawnSync)("systemctl", userArgs, {
574
+ const unit = resolveGatewayServiceName(process.env.OPENCLAW_PROFILE);
575
+ // Try user-level systemctl
576
+ const args = ["--user", "restart", unit];
577
+ tried.push(`systemctl ${args.join(" ")}`);
578
+ const result = (0, node_child_process_1.spawnSync)("systemctl", args, {
467
579
  encoding: "utf8",
468
580
  timeout: SPAWN_TIMEOUT_MS,
469
581
  });
470
- if (!userRestart.error && userRestart.status === 0) {
582
+ if (!result.error && result.status === 0) {
471
583
  logger.info("[podwatch/updater] Gateway restarted via systemctl --user.");
472
584
  return { ok: true, method: "systemd", tried };
473
585
  }
474
- // Fall back to system-level systemctl
475
- const systemArgs = ["restart", unit];
476
- tried.push(`systemctl ${systemArgs.join(" ")}`);
477
- const systemRestart = (0, node_child_process_1.spawnSync)("systemctl", systemArgs, {
586
+ // Single fallback: system-level
587
+ const sysArgs = ["restart", unit];
588
+ tried.push(`systemctl ${sysArgs.join(" ")}`);
589
+ const sysResult = (0, node_child_process_1.spawnSync)("systemctl", sysArgs, {
478
590
  encoding: "utf8",
479
591
  timeout: SPAWN_TIMEOUT_MS,
480
592
  });
481
- if (!systemRestart.error && systemRestart.status === 0) {
593
+ if (!sysResult.error && sysResult.status === 0) {
482
594
  logger.info("[podwatch/updater] Gateway restarted via systemctl (system).");
483
595
  return { ok: true, method: "systemd", tried };
484
596
  }
485
- // Both failed
486
- const detail = [
487
- `user: ${userRestart.error?.message ?? `exit ${userRestart.status}`}`,
488
- `system: ${systemRestart.error?.message ?? `exit ${systemRestart.status}`}`,
489
- ].join("; ");
490
- logger.error(`[podwatch/updater] systemctl restart failed: ${detail}. Please restart the gateway manually.`);
597
+ const detail = `user: ${result.error?.message ?? `exit ${result.status}`}; system: ${sysResult.error?.message ?? `exit ${sysResult.status}`}`;
598
+ logger.warn(`[podwatch/updater] Could not restart gateway: ${detail}. Restart manually to activate update.`);
491
599
  return { ok: false, method: "systemd", detail, tried };
492
600
  }
493
601
  if (process.platform === "darwin") {
494
- const label = process.env.OPENCLAW_LAUNCHD_LABEL ||
495
- resolveGatewayLaunchAgentLabel(process.env.OPENCLAW_PROFILE);
602
+ const profile = process.env.OPENCLAW_PROFILE?.trim();
603
+ const normalized = (!profile || profile.toLowerCase() === "default") ? null : profile;
604
+ const label = process.env.OPENCLAW_LAUNCHD_LABEL || (normalized ? `ai.openclaw.${normalized}` : "ai.openclaw.gateway");
496
605
  const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
497
606
  const target = uid !== undefined ? `gui/${uid}/${label}` : label;
498
607
  const args = ["kickstart", "-k", target];
@@ -506,97 +615,58 @@ function triggerGatewayRestart(logger) {
506
615
  return { ok: true, method: "launchctl", tried };
507
616
  }
508
617
  const detail = res.error?.message ?? `exit ${res.status}`;
509
- logger.error(`[podwatch/updater] launchctl restart failed: ${detail}. Please restart the gateway manually.`);
618
+ logger.warn(`[podwatch/updater] Could not restart gateway: ${detail}. Restart manually to activate update.`);
510
619
  return { ok: false, method: "launchctl", detail, tried };
511
620
  }
512
- // Unsupported platform
513
- logger.warn(`[podwatch/updater] Unsupported platform "${process.platform}" for auto-restart. Please restart the gateway manually.`);
514
- return {
515
- ok: false,
516
- method: "unsupported",
517
- detail: `unsupported platform: ${process.platform}`,
518
- tried,
519
- };
621
+ logger.info(`[podwatch/updater] Platform "${process.platform}" — restart manually to activate update.`);
622
+ return { ok: false, method: "unsupported", detail: `platform: ${process.platform}`, tried };
520
623
  }
521
624
  // ---------------------------------------------------------------------------
522
- // Urgent update trigger (called from transmitter when API signals urgent)
625
+ // Update lock (prevents concurrent update attempts)
523
626
  // ---------------------------------------------------------------------------
524
- /** Stored references for triggering urgent updates from the transmitter. */
525
- let urgentUpdateState = null;
526
627
  /**
527
- * Handle an urgent update signal from the Podwatch API.
528
- * Bypasses the 24-hour cooldown and triggers an immediate update check.
529
- * Called by the transmitter when the API response includes { update: { urgent: true } }.
628
+ * Acquire an exclusive update lock. Returns true if acquired, false if
629
+ * another update is already in progress (or the lock is stale and was reclaimed).
530
630
  */
531
- async function handleUrgentUpdate(signalVersion, logger) {
532
- if (!urgentUpdateState) {
533
- logger.warn("[podwatch/updater] Urgent update signal received but updater not initialized.");
534
- return;
535
- }
536
- const { currentVersion, endpoint, options } = urgentUpdateState;
537
- // Skip if autoUpdate is disabled
538
- if (options.autoUpdate === false) {
539
- logger.info("[podwatch/updater] Urgent update signal received but auto-update is disabled.");
540
- return;
541
- }
542
- // Skip if the signaled version is not newer
543
- if (compareVersions(currentVersion, signalVersion) <= 0) {
544
- logger.info(`[podwatch/updater] Urgent update signal for v${signalVersion} but already at v${currentVersion}. Skipping.`);
545
- return;
546
- }
547
- logger.info(`[podwatch/updater] Urgent update signal received for v${signalVersion}. Bypassing cooldown...`);
548
- // Run the update check immediately (bypasses shouldCheckForUpdate cooldown)
631
+ function acquireUpdateLock() {
549
632
  try {
550
- const result = await checkForUpdate(currentVersion, endpoint);
551
- writeCacheTimestamp();
552
- if (!result || !result.available) {
553
- logger.info("[podwatch/updater] Urgent check: no update available.");
554
- return;
555
- }
556
- if (!result.integrityHash) {
557
- logger.warn(`[podwatch/updater] Urgent check: no integrity hash for v${result.remoteVersion}. Skipping.`);
558
- return;
559
- }
560
- logger.info(`[podwatch/updater] Urgent update: v${currentVersion} → v${result.remoteVersion}. Downloading...`);
561
- const download = downloadTarball();
562
- if (!download.success || !download.tarballPath) {
563
- logger.error(`[podwatch/updater] Urgent update download failed: ${download.error}`);
564
- if (download.tmpDir) {
565
- try {
566
- node_fs_1.default.rmSync(download.tmpDir, { recursive: true, force: true });
633
+ node_fs_1.default.mkdirSync(CACHE_DIR, { recursive: true });
634
+ // Check for existing lock
635
+ if (node_fs_1.default.existsSync(UPDATE_LOCK_FILE)) {
636
+ try {
637
+ const raw = node_fs_1.default.readFileSync(UPDATE_LOCK_FILE, "utf-8");
638
+ const data = JSON.parse(raw);
639
+ const age = Date.now() - (data.ts ?? 0);
640
+ // Stale lock reclaim it
641
+ if (age > UPDATE_LOCK_STALE_MS) {
642
+ console.warn("[podwatch/updater] Reclaiming stale update lock.");
643
+ }
644
+ else {
645
+ return false; // Lock is held by another process
567
646
  }
568
- catch { /* best-effort */ }
569
- }
570
- return;
571
- }
572
- try {
573
- const integrity = verifyTarballIntegrity(download.tarballPath, result.integrityHash);
574
- if (!integrity.valid) {
575
- logger.error(`[podwatch/updater] Urgent update integrity check failed! ` +
576
- `Expected: ${result.integrityHash}, Got: ${integrity.actualHash ?? integrity.error}.`);
577
- return;
578
647
  }
579
- const installResult = installFromTarball(download.tarballPath);
580
- if (!installResult.success) {
581
- logger.error(`[podwatch/updater] Urgent update install failed: ${installResult.error}`);
582
- return;
648
+ catch {
649
+ // Corrupted lock file — reclaim
583
650
  }
584
- logger.info(`[podwatch/updater] Urgent update installed (v${currentVersion} → v${result.remoteVersion}). Waiting for idle window before restart...`);
585
- await waitForIdleWindow(logger);
586
- writeRestartSentinel(result.remoteVersion);
587
- triggerGatewayRestart(logger);
588
651
  }
589
- finally {
590
- if (download.tmpDir) {
591
- try {
592
- node_fs_1.default.rmSync(download.tmpDir, { recursive: true, force: true });
593
- }
594
- catch { /* best-effort */ }
595
- }
652
+ node_fs_1.default.writeFileSync(UPDATE_LOCK_FILE, JSON.stringify({ pid: process.pid, ts: Date.now() }));
653
+ return true;
654
+ }
655
+ catch {
656
+ return false;
657
+ }
658
+ }
659
+ /**
660
+ * Release the update lock.
661
+ */
662
+ function releaseUpdateLock() {
663
+ try {
664
+ if (node_fs_1.default.existsSync(UPDATE_LOCK_FILE)) {
665
+ node_fs_1.default.rmSync(UPDATE_LOCK_FILE, { force: true });
596
666
  }
597
667
  }
598
- catch (err) {
599
- logger.error(`[podwatch/updater] Urgent update error: ${err}`);
668
+ catch {
669
+ // Best-effort
600
670
  }
601
671
  }
602
672
  // ---------------------------------------------------------------------------
@@ -606,7 +676,7 @@ async function handleUrgentUpdate(signalVersion, logger) {
606
676
  * Schedule a non-blocking update check 30s after startup.
607
677
  * Safe to call synchronously — it sets a timer and returns immediately.
608
678
  *
609
- * Auto-update is on by default. Set autoUpdate: false in plugin config to disable.
679
+ * Auto-update is ON by default. Set autoUpdate: false in plugin config to disable.
610
680
  *
611
681
  * @param currentVersion The plugin's current version from package.json
612
682
  * @param endpoint The Podwatch API endpoint (for dashboard fallback)
@@ -614,8 +684,6 @@ async function handleUrgentUpdate(signalVersion, logger) {
614
684
  * @param options Update options (autoUpdate, etc.)
615
685
  */
616
686
  function scheduleUpdateCheck(currentVersion, endpoint, logger, options = {}) {
617
- // Store state for urgent update triggers from the transmitter
618
- urgentUpdateState = { currentVersion, endpoint, logger, options };
619
687
  const timer = setTimeout(() => {
620
688
  void runUpdateCheck(currentVersion, endpoint, logger, options);
621
689
  }, STARTUP_DELAY_MS);
@@ -625,21 +693,25 @@ function scheduleUpdateCheck(currentVersion, endpoint, logger, options = {}) {
625
693
  }
626
694
  }
627
695
  /**
628
- * Internal: perform the actual update check + install + restart.
696
+ * Internal: perform the actual update check + install.
629
697
  *
630
698
  * Flow:
631
- * 1. Check autoUpdate opt-in (default false)
699
+ * 1. Check autoUpdate opt-out (default on; set autoUpdate: false to disable)
632
700
  * 2. Respect 24h cooldown
633
- * 3. Check for newer version (npm dashboard fallback)
634
- * 4. Require integrity hash from server (skip if missing)
635
- * 5. Download tarball via npm pack
636
- * 6. Verify tarball SHA-256 against server hash
637
- * 7. Extract and install
638
- * 8. Write restart sentinel + trigger gateway restart
701
+ * 3. Acquire update lock (prevents concurrent updates)
702
+ * 4. Check for newer version (npm dashboard fallback)
703
+ * 5. Require integrity hash from server (skip if missing)
704
+ * 6. Download tarball via fetch
705
+ * 7. Verify tarball integrity against server hash
706
+ * 8. Extract and install (with rollback on failure)
707
+ * 9. Verify new dist loads; rollback if broken
708
+ * 10. Write restart sentinel (gateway picks it up on next restart)
709
+ * 11. Wait for idle window, then restart gateway
639
710
  */
640
711
  async function runUpdateCheck(currentVersion, endpoint, logger, options = {}) {
712
+ let lockAcquired = false;
641
713
  try {
642
- // 1. Check opt-in — auto-update must be explicitly enabled
714
+ // 1. Check opt-in — auto-update must be explicitly disabled
643
715
  if (options.autoUpdate === false) {
644
716
  logger.info("[podwatch/updater] Auto-update is disabled. Remove autoUpdate: false from plugin config to re-enable.");
645
717
  return;
@@ -649,6 +721,12 @@ async function runUpdateCheck(currentVersion, endpoint, logger, options = {}) {
649
721
  console.log("[podwatch/updater] Skipping check — within 24h cooldown.");
650
722
  return;
651
723
  }
724
+ // 3. Acquire update lock (prevents concurrent updates)
725
+ if (!acquireUpdateLock()) {
726
+ logger.info("[podwatch/updater] Another update is in progress — skipping.");
727
+ return;
728
+ }
729
+ lockAcquired = true;
652
730
  logger.info("[podwatch/updater] Checking for updates...");
653
731
  const result = await checkForUpdate(currentVersion, endpoint);
654
732
  // Always write cache timestamp after a check attempt (even if no update)
@@ -669,13 +747,10 @@ async function runUpdateCheck(currentVersion, endpoint, logger, options = {}) {
669
747
  }
670
748
  // New version available with integrity hash!
671
749
  logger.info(`[podwatch/updater] Update available: v${currentVersion} → v${result.remoteVersion} (via ${result.source}). Downloading...`);
672
- // 4. Download tarball
673
- const download = downloadTarball();
750
+ // 4. Download tarball via fetch
751
+ const download = await downloadTarball(result.remoteVersion, result.tarballUrl);
674
752
  if (!download.success || !download.tarballPath) {
675
753
  logger.error(`[podwatch/updater] Update failed: ${download.error}. Continuing with current version.`);
676
- if (download.stderr) {
677
- console.error("[podwatch/updater] stderr:", download.stderr);
678
- }
679
754
  // Cleanup temp dir
680
755
  if (download.tmpDir) {
681
756
  try {
@@ -696,7 +771,7 @@ async function runUpdateCheck(currentVersion, endpoint, logger, options = {}) {
696
771
  return;
697
772
  }
698
773
  logger.info(`[podwatch/updater] Integrity verified (${integrity.actualHash}). Installing v${result.remoteVersion}...`);
699
- // 6. Extract and install
774
+ // 6. Extract and install (with rollback on failure)
700
775
  const installResult = installFromTarball(download.tarballPath);
701
776
  if (!installResult.success) {
702
777
  logger.error(`[podwatch/updater] Update failed: ${installResult.error}. Continuing with current version.`);
@@ -705,13 +780,54 @@ async function runUpdateCheck(currentVersion, endpoint, logger, options = {}) {
705
780
  }
706
781
  return;
707
782
  }
708
- logger.info(`[podwatch/updater] Update installed successfully (v${currentVersion} v${result.remoteVersion}). Waiting for idle window before restart...`);
709
- // 7. Wait for idle window (defer if agent is active)
710
- await waitForIdleWindow(logger);
711
- // 8. Write restart sentinel so the gateway knows why it restarted
783
+ // 7. Verify the new dist loads before doing anything dangerous
784
+ const extensionsDir = node_path_1.default.join(process.env.HOME ?? process.env.USERPROFILE ?? "/tmp", ".openclaw", "extensions", "podwatch");
785
+ const distResult = verifyNewDist(extensionsDir);
786
+ const backupDir = node_path_1.default.join(extensionsDir, "dist.backup");
787
+ if (!distResult.ok) {
788
+ const reason = distResult.signal
789
+ ? `crashed with signal ${distResult.signal}`
790
+ : distResult.timedOut
791
+ ? "timed out during load"
792
+ : distResult.error || "unknown error";
793
+ logger.error(`[podwatch/updater] New dist/index.js failed to load (${reason})! Rolling back to previous version.`);
794
+ if (node_fs_1.default.existsSync(backupDir)) {
795
+ restoreFromBackup(extensionsDir, backupDir);
796
+ logger.info("[podwatch/updater] Rolled back to previous version.");
797
+ }
798
+ else {
799
+ logger.error("[podwatch/updater] No backup available for rollback — previous dist lost.");
800
+ }
801
+ transmitter_js_1.transmitter.enqueue({
802
+ type: "alert",
803
+ ts: Date.now(),
804
+ message: `Podwatch update to v${result.remoteVersion} failed verification (${reason}) — rolled back. Please report this.`,
805
+ severity: "error",
806
+ });
807
+ return;
808
+ }
809
+ // 7b. Verification passed — clean up backup now
810
+ cleanupBackup(backupDir);
811
+ // 8. Write restart sentinel
712
812
  writeRestartSentinel(result.remoteVersion);
713
- // 9. Trigger restart via OS service manager
714
- triggerGatewayRestart(logger);
813
+ // 9. Notify dashboard
814
+ transmitter_js_1.transmitter.enqueue({
815
+ type: "alert",
816
+ ts: Date.now(),
817
+ message: `Podwatch updated: v${currentVersion} → v${result.remoteVersion}. Restarting gateway...`,
818
+ severity: "info",
819
+ });
820
+ // 10. Flush transmitter so the notification gets out before restart
821
+ await transmitter_js_1.transmitter.flush();
822
+ logger.info(`[podwatch/updater] Update verified and installed (v${currentVersion} → v${result.remoteVersion}). ` +
823
+ "Waiting for idle window before restart...");
824
+ // 11. Wait for idle window, then restart
825
+ await waitForIdleWindow(logger);
826
+ const restartResult = triggerGatewayRestart(logger);
827
+ if (!restartResult.ok) {
828
+ logger.info(`[podwatch/updater] Automatic restart not available. Update is installed — ` +
829
+ "restart the gateway manually to activate.");
830
+ }
715
831
  }
716
832
  finally {
717
833
  // Cleanup temp dir
@@ -727,5 +843,9 @@ async function runUpdateCheck(currentVersion, endpoint, logger, options = {}) {
727
843
  // Catch-all: never let the updater crash the plugin
728
844
  console.error("[podwatch/updater] Unexpected error:", err);
729
845
  }
846
+ finally {
847
+ if (lockAcquired)
848
+ releaseUpdateLock();
849
+ }
730
850
  }
731
851
  //# sourceMappingURL=updater.js.map