@0xmaxma/claude-gateway 1.7.9 → 1.7.10

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.
Files changed (104) hide show
  1. package/README.md +11 -3
  2. package/dist/agent/dreaming/accept.d.ts.map +1 -1
  3. package/dist/agent/dreaming/accept.js +4 -1
  4. package/dist/agent/dreaming/accept.js.map +1 -1
  5. package/dist/agent/dreaming/config.d.ts.map +1 -1
  6. package/dist/agent/dreaming/config.js +2 -0
  7. package/dist/agent/dreaming/config.js.map +1 -1
  8. package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
  9. package/dist/agent/dreaming/reviewer.js +9 -2
  10. package/dist/agent/dreaming/reviewer.js.map +1 -1
  11. package/dist/agent/dreaming/staleness.d.ts +7 -1
  12. package/dist/agent/dreaming/staleness.d.ts.map +1 -1
  13. package/dist/agent/dreaming/staleness.js +17 -2
  14. package/dist/agent/dreaming/staleness.js.map +1 -1
  15. package/dist/agent/dreaming/types.d.ts +17 -1
  16. package/dist/agent/dreaming/types.d.ts.map +1 -1
  17. package/dist/agent/knowledge/archive-db.d.ts +43 -0
  18. package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
  19. package/dist/agent/knowledge/archive-db.js +130 -0
  20. package/dist/agent/knowledge/archive-db.js.map +1 -1
  21. package/dist/agent/knowledge/config.d.ts +3 -1
  22. package/dist/agent/knowledge/config.d.ts.map +1 -1
  23. package/dist/agent/knowledge/config.js +30 -1
  24. package/dist/agent/knowledge/config.js.map +1 -1
  25. package/dist/agent/knowledge/index.d.ts +11 -4
  26. package/dist/agent/knowledge/index.d.ts.map +1 -1
  27. package/dist/agent/knowledge/index.js +22 -1
  28. package/dist/agent/knowledge/index.js.map +1 -1
  29. package/dist/agent/knowledge/indexer.d.ts +0 -7
  30. package/dist/agent/knowledge/indexer.d.ts.map +1 -1
  31. package/dist/agent/knowledge/indexer.js +34 -7
  32. package/dist/agent/knowledge/indexer.js.map +1 -1
  33. package/dist/agent/knowledge/lifecycle.d.ts +20 -4
  34. package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
  35. package/dist/agent/knowledge/lifecycle.js +33 -4
  36. package/dist/agent/knowledge/lifecycle.js.map +1 -1
  37. package/dist/agent/knowledge/reflection.d.ts +114 -0
  38. package/dist/agent/knowledge/reflection.d.ts.map +1 -0
  39. package/dist/agent/knowledge/reflection.js +447 -0
  40. package/dist/agent/knowledge/reflection.js.map +1 -0
  41. package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
  42. package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
  43. package/dist/agent/knowledge/shared-dedup.js +187 -0
  44. package/dist/agent/knowledge/shared-dedup.js.map +1 -0
  45. package/dist/agent/knowledge/shared-promote.d.ts +82 -7
  46. package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
  47. package/dist/agent/knowledge/shared-promote.js +244 -11
  48. package/dist/agent/knowledge/shared-promote.js.map +1 -1
  49. package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
  50. package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
  51. package/dist/agent/knowledge/shared-staleness.js +149 -0
  52. package/dist/agent/knowledge/shared-staleness.js.map +1 -0
  53. package/dist/agent/knowledge/shared-writer.d.ts +19 -0
  54. package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
  55. package/dist/agent/knowledge/shared-writer.js +42 -0
  56. package/dist/agent/knowledge/shared-writer.js.map +1 -1
  57. package/dist/agent/knowledge/types.d.ts +24 -0
  58. package/dist/agent/knowledge/types.d.ts.map +1 -1
  59. package/dist/agent/runner.d.ts +7 -0
  60. package/dist/agent/runner.d.ts.map +1 -1
  61. package/dist/agent/runner.js +92 -15
  62. package/dist/agent/runner.js.map +1 -1
  63. package/dist/agent/workspace-loader.d.ts +17 -2
  64. package/dist/agent/workspace-loader.d.ts.map +1 -1
  65. package/dist/agent/workspace-loader.js +17 -1
  66. package/dist/agent/workspace-loader.js.map +1 -1
  67. package/dist/api/gateway-router.d.ts.map +1 -1
  68. package/dist/api/gateway-router.js +2 -1
  69. package/dist/api/gateway-router.js.map +1 -1
  70. package/dist/apps/compose-generator.js +7 -4
  71. package/dist/apps/compose-generator.js.map +1 -1
  72. package/dist/apps/installer.d.ts +86 -0
  73. package/dist/apps/installer.d.ts.map +1 -1
  74. package/dist/apps/installer.js +440 -52
  75. package/dist/apps/installer.js.map +1 -1
  76. package/dist/index.js +37 -1
  77. package/dist/index.js.map +1 -1
  78. package/dist/session/process.d.ts +20 -3
  79. package/dist/session/process.d.ts.map +1 -1
  80. package/dist/session/process.js +120 -5
  81. package/dist/session/process.js.map +1 -1
  82. package/dist/shell/claude-pty-shell.js +146 -27
  83. package/dist/shell/claude-pty-shell.js.map +1 -1
  84. package/dist/shell/draft-phantom.d.ts +49 -0
  85. package/dist/shell/draft-phantom.d.ts.map +1 -0
  86. package/dist/shell/draft-phantom.js +59 -0
  87. package/dist/shell/draft-phantom.js.map +1 -0
  88. package/dist/shell/submit-diag.d.ts +67 -0
  89. package/dist/shell/submit-diag.d.ts.map +1 -0
  90. package/dist/shell/submit-diag.js +69 -0
  91. package/dist/shell/submit-diag.js.map +1 -0
  92. package/dist/types.d.ts +25 -0
  93. package/dist/types.d.ts.map +1 -1
  94. package/mcp/tools/discord/module.ts +23 -15
  95. package/mcp/tools/memory/archive-reader.test.ts +25 -1
  96. package/mcp/tools/memory/archive-reader.ts +70 -12
  97. package/mcp/tools/memory/archive-writer.test.ts +168 -0
  98. package/mcp/tools/memory/archive-writer.ts +137 -0
  99. package/mcp/tools/memory/module.test.ts +187 -0
  100. package/mcp/tools/memory/module.ts +192 -6
  101. package/mcp/tools/telegram/module.ts +10 -2
  102. package/mcp/tools/telegram/receiver-server.ts +9 -2
  103. package/mcp/tools/telegram/typing.ts +178 -34
  104. package/package.json +1 -1
@@ -96,6 +96,42 @@ function isValidTimezone(tz) {
96
96
  // Disallow '..' in owner/repo segments — prevents path traversal via edge-case git URL parsing.
97
97
  const GITHUB_URL_RE = /^https:\/\/github\.com\/(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*(\.git)?$/;
98
98
  // ─── Installer ────────────────────────────────────────────────────────────────
99
+ const UUID_RE_SRC = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
100
+ /**
101
+ * Scratch directories `runUpdate` creates beside an app's install path:
102
+ * `.cg-update-<app>-<uuid>` (staging checkout) and `<appDir>-old-<uuid>` /
103
+ * `<appDir>-failed-<uuid>` (release snapshots). All three carry a v4 UUID, so
104
+ * the pattern cannot match an app directory named by a user.
105
+ */
106
+ const STALE_UPDATE_DIR_RE = new RegExp(`^\\.cg-update-.+-${UUID_RE_SRC}$|-(?:old|failed)-${UUID_RE_SRC}$`);
107
+ /**
108
+ * Rows from a `docker compose … --format json` call. Compose has emitted both a
109
+ * single JSON array and one object per line across its 2.x line, so accept
110
+ * either rather than silently reading a newer/older daemon as "nothing here".
111
+ */
112
+ function parseJsonRows(stdout) {
113
+ const text = stdout.trim();
114
+ if (!text)
115
+ return [];
116
+ try {
117
+ const parsed = JSON.parse(text);
118
+ return Array.isArray(parsed) ? parsed : [parsed];
119
+ }
120
+ catch { /* not a single document — try line-delimited below */ }
121
+ const rows = [];
122
+ for (const line of text.split('\n')) {
123
+ const trimmed = line.trim();
124
+ if (!trimmed)
125
+ continue;
126
+ try {
127
+ rows.push(JSON.parse(trimmed));
128
+ }
129
+ catch { /* skip a line that is not JSON */ }
130
+ }
131
+ return rows;
132
+ }
133
+ /** Private tag the update holds on an image so a rollback can put it back. */
134
+ const ROLLBACK_TAG_PREFIX = 'cg-rollback-';
99
135
  class AppInstaller {
100
136
  constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn, housekeepingConfig = {}, appBackupConfig, backupsDir) {
101
137
  this.registry = registry;
@@ -720,23 +756,29 @@ class AppInstaller {
720
756
  * read-only `~/.claude/projects`) are intentionally excluded. Best-effort:
721
757
  * returns `[]` on any failure, mirroring {@link discoverVolumes}.
722
758
  */
723
- discoverBindMounts(appName, appDir) {
759
+ /**
760
+ * The paths a generated compose file may have resolved this app dir to.
761
+ * Local-dev installs symlink the app dir into appsDir, and the generated
762
+ * compose resolves bind sources against the symlink's *realpath*, so both the
763
+ * symlink path and its target must be treated as the app root.
764
+ */
765
+ bindMountBases(appDir) {
766
+ const bases = [appDir];
767
+ try {
768
+ const real = fs.realpathSync(appDir);
769
+ if (real !== appDir)
770
+ bases.push(real);
771
+ }
772
+ catch {
773
+ /* app dir unreadable — fall back to the literal path */
774
+ }
775
+ return bases;
776
+ }
777
+ discoverBindMounts(appName, appDir, onError = 'empty') {
778
+ const bases = this.bindMountBases(appDir);
724
779
  try {
725
780
  const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--format', 'json'], appDir, 30000);
726
781
  const parsed = JSON.parse(stdout);
727
- // Local-dev installs symlink the app dir into appsDir, and the generated
728
- // compose resolves bind sources against the symlink's *realpath*. Match a
729
- // source that sits under either the symlink path or its target, so those
730
- // bind mounts are not wrongly excluded.
731
- const bases = [appDir];
732
- try {
733
- const real = fs.realpathSync(appDir);
734
- if (real !== appDir)
735
- bases.push(real);
736
- }
737
- catch {
738
- /* app dir unreadable — fall back to the literal path */
739
- }
740
782
  const rels = new Set();
741
783
  for (const svc of Object.values(parsed.services ?? {})) {
742
784
  for (const vol of svc.volumes ?? []) {
@@ -753,8 +795,18 @@ class AppInstaller {
753
795
  }
754
796
  return Array.from(rels).sort();
755
797
  }
756
- catch {
757
- return [];
798
+ catch (err) {
799
+ if (onError === 'empty')
800
+ return [];
801
+ // Fail closed: the caller is about to move this app's directory, so an
802
+ // unknown bind set would silently strand live state. Only an app whose
803
+ // stored compose anchors nothing to the app dir (under either base) is
804
+ // safe to treat as bind-free.
805
+ const composePath = path.join(appDir, 'docker-compose.yml');
806
+ const compose = fs.existsSync(composePath) ? fs.readFileSync(composePath, 'utf-8') : '';
807
+ if (!bases.some((b) => compose.includes(b)))
808
+ return [];
809
+ throw new Error(`Cannot safely discover bind mounts for update: ${err.message}`);
758
810
  }
759
811
  }
760
812
  /**
@@ -823,6 +875,52 @@ class AppInstaller {
823
875
  * Returns a cancel function. No-op (returns a noop canceller) when both caps
824
876
  * are disabled. The timer is `unref`'d so it never holds the event loop open.
825
877
  */
878
+ /**
879
+ * Reclaim update scratch directories a crashed or killed update left behind:
880
+ * the `.cg-update-*` staging checkout and the `-old-`/`-failed-` release
881
+ * snapshots. Staging moved next to the install path so the swap is a
882
+ * same-filesystem rename, which also means `/tmp` cleanup no longer collects
883
+ * it — without this sweep a mid-update crash leaks a full app checkout
884
+ * forever.
885
+ *
886
+ * **Boot only.** An update in flight owns directories matching these names,
887
+ * so this must run before any update can start. Never throws; a directory it
888
+ * cannot remove is reported and skipped.
889
+ */
890
+ async sweepStaleUpdateDirs() {
891
+ const swept = [];
892
+ let names;
893
+ try {
894
+ names = fs.readdirSync(this.appsDir);
895
+ }
896
+ catch {
897
+ return swept; // no apps dir yet
898
+ }
899
+ // An installPath is authoritative — never remove a directory an app still
900
+ // points at, however its name happens to look.
901
+ let live;
902
+ try {
903
+ live = new Set((await this.registry.list()).map((a) => a.installPath));
904
+ }
905
+ catch {
906
+ return swept; // registry unreadable — do not guess
907
+ }
908
+ for (const name of names) {
909
+ if (!STALE_UPDATE_DIR_RE.test(name))
910
+ continue;
911
+ const full = path.join(this.appsDir, name);
912
+ if (live.has(full))
913
+ continue;
914
+ try {
915
+ this.rmrf(full);
916
+ swept.push(full);
917
+ }
918
+ catch (err) {
919
+ console.warn(`[installer] failed to sweep stale update dir "${full}": ${err.message}`);
920
+ }
921
+ }
922
+ return swept;
923
+ }
826
924
  startBackupCleanup() {
827
925
  const { retention, maxAgeDays, cleanupHour, cleanupTimezone } = this.appBackupConfig;
828
926
  if (retention <= 0 && maxAgeDays <= 0)
@@ -1357,7 +1455,12 @@ class AppInstaller {
1357
1455
  this.log(job, `WARNING: pre-update backup failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
1358
1456
  }
1359
1457
  }
1360
- const tmpDir = path.join(os.tmpdir(), `cg-update-${appName}-${crypto.randomUUID()}`);
1458
+ // Stage beside the durable install path so the directory swap and bind-data
1459
+ // moves are same-filesystem renames, not cross-device copies/failures.
1460
+ const tmpDir = path.join(path.dirname(entry.installPath), `.cg-update-${appName}-${crypto.randomUUID()}`);
1461
+ // Rollback tags held on the images this update is about to build over.
1462
+ // Declared out here so every exit path can drop them again.
1463
+ let preservedImages = [];
1361
1464
  try {
1362
1465
  // ── Shallow fetch of specific commit into tmp dir ─────────────────────
1363
1466
  this.log(job, `Cloning ${target.repo}`);
@@ -1392,12 +1495,20 @@ class AppInstaller {
1392
1495
  version: newVersion,
1393
1496
  commit: target.newCommit,
1394
1497
  installPath: tmpDir,
1395
- ...(generated.agentDeclaration !== null ? { agentDeclaration: generated.agentDeclaration } : {}),
1498
+ agentDeclaration: generated.agentDeclaration,
1396
1499
  ...(agentPaths ? { agentPaths } : {}),
1397
1500
  };
1398
1501
  if (generated.agentDeclaration && this.agentManager && agentPaths) {
1399
1502
  this.agentManager.injectAgentService(newEntry);
1400
1503
  }
1504
+ // Pin the images the app is running before the build takes their tags
1505
+ // over. A `build:` service's new image reuses the old tag
1506
+ // (`<project>-<service>`), so once the build succeeds that tag names the
1507
+ // new release: rolling only the source back would bring the app up on the
1508
+ // failed release's image and crash-loop on a "successful" rollback. This
1509
+ // has to happen before the build — afterwards the old image has no
1510
+ // reference left to grab it by.
1511
+ preservedImages = this.preserveRunningImages(appName, entry.installPath, job);
1401
1512
  // ── Build new images in tmp dir ───────────────────────────────────────
1402
1513
  this.log(job, 'Building new images');
1403
1514
  this.run(['docker', 'compose', '-p', appName, 'build'], tmpDir, 600000);
@@ -1409,30 +1520,72 @@ class AppInstaller {
1409
1520
  this.log(job, 'MEMORY.md backed up');
1410
1521
  }
1411
1522
  }
1523
+ // Discover the app-owned bind paths while the old compose file still points
1524
+ // at the durable install directory. They must move with the release swap so
1525
+ // cleanup cannot delete live state. This query fails closed, so it runs
1526
+ // *before* routes and sockets come down — a throw here would otherwise
1527
+ // leave the app running but unreachable, with no path back.
1528
+ const oldBindMounts = this.discoverBindMounts(appName, entry.installPath, 'throw');
1412
1529
  // ── Deregister old routes before taking down containers ───────────────
1413
1530
  this.callbacks.deregisterRoutes(appName);
1414
1531
  this.callbacks.stopSockets(appName);
1415
- // Capture the current (old) image IDs while the old stack is still up, so
1416
- // we can reclaim exactly those images after the update — without a
1417
- // `compose down` that would collide with the new stack (issue #283).
1532
+ // Capture the current (old) image IDs while the old compose file is still
1533
+ // the live one, so the reclaim below targets exactly this app's images.
1418
1534
  const oldImageIds = this.captureComposeImageIds(appName, entry.installPath);
1419
- // Also capture the app's declared image refs (repo:tag) so a superseded
1420
- // *pulled* tag can be reclaimed after the update (issue #302).
1421
1535
  const oldImageRefs = this.captureComposeImageRefs(appName, entry.installPath);
1422
- // ── Bring old containers down (keeps images for rollback) ─────────────
1536
+ // ── Swap dirs before starting the new containers ───────────────────────
1537
+ // The new compose file's bind sources are anchored to finalDir. Starting
1538
+ // before this swap would bind the old directory inode then delete it.
1423
1539
  this.log(job, 'Stopping old containers');
1424
1540
  this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
1425
- // ── Start new containers ──────────────────────────────────────────────
1426
- this.log(job, 'Starting new containers');
1541
+ this.log(job, 'Swapping app directories');
1542
+ const finalDir = entry.installPath;
1543
+ const oldBackupDir = `${finalDir}-old-${crypto.randomUUID()}`;
1544
+ let swapped = false;
1545
+ let failedDir = null;
1546
+ const movedBindMounts = [];
1427
1547
  try {
1428
- this.composeUp(appName, tmpDir, job);
1548
+ fs.renameSync(finalDir, oldBackupDir);
1549
+ fs.renameSync(tmpDir, finalDir);
1550
+ swapped = true;
1551
+ this.moveBindMounts(oldBackupDir, finalDir, oldBindMounts, job, movedBindMounts);
1552
+ // The source checkout is now permanent. Rewrite the compose file with
1553
+ // finalDir as its base so every bind source and build context resolves
1554
+ // to the durable path — this call is kept for that side effect; its
1555
+ // return value necessarily matches `generated` (same app.yaml). The
1556
+ // rewrite drops the injected agent service, so re-inject it after.
1557
+ const finalComposePath = path.join(finalDir, 'docker-compose.yml');
1558
+ const finalYaml = (0, compose_generator_1.parseAppYaml)(fs.readFileSync(path.join(finalDir, 'app.yaml'), 'utf-8'), finalDir);
1559
+ (0, compose_generator_1.generateCompose)(finalYaml, appName, finalDir, finalComposePath);
1560
+ if (generated.agentDeclaration && this.agentManager && agentPaths) {
1561
+ this.agentManager.injectAgentService({ ...newEntry, installPath: finalDir });
1562
+ }
1563
+ // ── Start new containers ────────────────────────────────────────────
1564
+ this.log(job, 'Starting new containers');
1565
+ this.composeUp(appName, finalDir, job);
1429
1566
  }
1430
1567
  catch (upErr) {
1431
- // Rollback: bring old containers back up from old install path
1432
1568
  this.log(job, 'New containers failed — rolling back to previous version');
1433
1569
  let rollbackFailed = false;
1570
+ failedDir = `${finalDir}-failed-${crypto.randomUUID()}`;
1434
1571
  try {
1435
- this.run(['docker', 'compose', '-p', appName, 'up', '-d'], entry.installPath, 120000);
1572
+ if (swapped) {
1573
+ fs.renameSync(finalDir, failedDir);
1574
+ fs.renameSync(oldBackupDir, finalDir);
1575
+ this.restoreBindMounts(failedDir, finalDir, movedBindMounts, job);
1576
+ }
1577
+ else if (fs.existsSync(oldBackupDir)) {
1578
+ fs.renameSync(oldBackupDir, finalDir);
1579
+ }
1580
+ // Point every tag back at the image the app was actually running
1581
+ // before this update built over it, so the restored source and the
1582
+ // restored image are the same release. Falls back to rebuilding from
1583
+ // the restored source when an old image is no longer on disk.
1584
+ const rebuild = !this.restorePreservedImages(preservedImages, finalDir, job);
1585
+ const upArgs = ['docker', 'compose', '-p', appName, 'up', '-d'];
1586
+ if (rebuild)
1587
+ upArgs.push('--build');
1588
+ this.run(upArgs, finalDir, rebuild ? 600000 : 120000);
1436
1589
  this.callbacks.registerRoutes(appName, entry.ports.map((p) => ({
1437
1590
  name: p.name,
1438
1591
  service: p.service,
@@ -1442,38 +1595,27 @@ class AppInstaller {
1442
1595
  rateLimit: p.rateLimit,
1443
1596
  })));
1444
1597
  await this.registry.updateStatus(appName, 'running');
1598
+ if (failedDir !== null)
1599
+ this.safeRmrf(failedDir, job, 'failed update dir');
1445
1600
  }
1446
1601
  catch (rollbackErr) {
1447
1602
  rollbackFailed = true;
1448
1603
  this.log(job, `ROLLBACK FAILED — app "${appName}" may be in a broken state: ${rollbackErr.message}`);
1449
1604
  }
1450
- this.safeRmrf(tmpDir, job, 'update temp dir');
1451
1605
  if (rollbackFailed) {
1452
1606
  throw new Error(`Update failed and rollback also failed — app "${appName}" may be in a broken state. Check job logs for details.`);
1453
1607
  }
1454
1608
  throw upErr;
1455
1609
  }
1456
- // Capture the new stack's image IDs (still at tmpDir) so image reclamation
1457
- // below never removes an image the new containers depend on (e.g. when the
1458
- // old and new versions happen to share a base/image).
1459
- const newImageIds = this.captureComposeImageIds(appName, tmpDir);
1460
- const newImageRefs = this.captureComposeImageRefs(appName, tmpDir);
1461
- // ── Swap dirs ─────────────────────────────────────────────────────────
1462
- // Swap in place at the recorded install path — NOT path.join(appsDir, appName).
1463
- // For legacy installs the on-disk dir is named after the source repo/URL
1464
- // basename, so `installPath` basename can differ from the app name. Using
1465
- // the app name here throws ENOENT (issue #275). `entry.installPath` is the
1466
- // authoritative location the `down`/rollback steps above already use.
1467
- this.log(job, 'Swapping app directories');
1468
- const finalDir = entry.installPath;
1469
- const oldBackupDir = `${finalDir}-old-${crypto.randomUUID()}`;
1470
- fs.renameSync(finalDir, oldBackupDir);
1471
- fs.renameSync(tmpDir, finalDir);
1472
- // ── Restore MEMORY.md ─────────────────────────────────────────────────
1473
- if (memoryBackup !== null && generated.agentDeclaration && this.agentManager) {
1474
- this.agentManager.restoreMemory(generated.agentDeclaration.name, memoryBackup);
1475
- this.log(job, 'MEMORY.md restored');
1476
- }
1610
+ // The new stack is up: the previous images are no longer a rollback
1611
+ // target. Untag them before the reclaim below, which removes by image ID
1612
+ // and would be refused while a second reference exists.
1613
+ this.dropPreservedImageTags(preservedImages);
1614
+ preservedImages = [];
1615
+ // Capture the new stack's image IDs from its permanent compose file so
1616
+ // cleanup below never removes an image the new containers depend on.
1617
+ const newImageIds = this.captureComposeImageIds(appName, finalDir);
1618
+ const newImageRefs = this.captureComposeImageRefs(appName, finalDir);
1477
1619
  // ── Update registry ───────────────────────────────────────────────────
1478
1620
  const finalEntry = {
1479
1621
  ...newEntry,
@@ -1482,10 +1624,28 @@ class AppInstaller {
1482
1624
  status: 'running',
1483
1625
  };
1484
1626
  await this.registry.upsert(finalEntry);
1485
- // ── Re-create agent symlink + config.json entry ───────────────────────
1627
+ // ── Re-create, rename, or remove the app-agent registration ───────────
1628
+ // `upsertAgent` keys off the *new* agent name, so a release that drops or
1629
+ // renames its agent would otherwise strand the previous workspace symlink
1630
+ // and config.json entry. Both transitions are the same deregistration.
1631
+ const oldAgentName = entry.agentDeclaration?.name ?? null;
1632
+ const newAgentName = generated.agentDeclaration?.name ?? null;
1633
+ if (this.agentManager && oldAgentName !== null && oldAgentName !== newAgentName) {
1634
+ await this.agentManager.deleteAgentByName(oldAgentName);
1635
+ this.log(job, newAgentName === null
1636
+ ? `Agent "${oldAgentName}" removed`
1637
+ : `Agent "${oldAgentName}" deregistered (renamed to "${newAgentName}")`);
1638
+ }
1486
1639
  if (generated.agentDeclaration && this.agentManager) {
1487
1640
  await this.agentManager.upsertAgent(finalEntry);
1488
1641
  this.log(job, `Agent "${generated.agentDeclaration.name}" re-registered`);
1642
+ // MEMORY.md must be written *after* registration: restoreMemory resolves
1643
+ // the workspace through config.json, so on a rename the new name is not
1644
+ // resolvable until upsertAgent has written its entry.
1645
+ if (memoryBackup !== null) {
1646
+ this.agentManager.restoreMemory(generated.agentDeclaration.name, memoryBackup);
1647
+ this.log(job, 'MEMORY.md restored');
1648
+ }
1489
1649
  await this.callbacks.reinitializeAgent?.(generated.agentDeclaration.name);
1490
1650
  }
1491
1651
  // ── Re-register proxy routes + sockets ───────────────────────────────
@@ -1534,6 +1694,7 @@ class AppInstaller {
1534
1694
  this.log(job, `Update complete → ${newVersion}`);
1535
1695
  }
1536
1696
  catch (err) {
1697
+ this.dropPreservedImageTags(preservedImages);
1537
1698
  if (fs.existsSync(tmpDir)) {
1538
1699
  this.safeRmrf(tmpDir, job, 'update temp dir');
1539
1700
  }
@@ -1975,6 +2136,135 @@ class AppInstaller {
1975
2136
  }
1976
2137
  }
1977
2138
  }
2139
+ /**
2140
+ * Create `targetDir` under `baseDir`, refusing to traverse or create through a
2141
+ * symlink. The check runs **before** each segment is created — a `mkdir -p`
2142
+ * that ran first would already have materialised directories on the far side
2143
+ * of a symlink, leaving the guard with nothing left to prevent.
2144
+ *
2145
+ * Scope: this guards the **destination** side — the freshly checked-out
2146
+ * release, which is the side an app repo controls. A bind path that was
2147
+ * already a symlink in the *previous* app dir is carried across as-is; that
2148
+ * preserves an escape the operator set up themselves rather than creating
2149
+ * one, and is the behaviour every release before this one had.
2150
+ */
2151
+ ensureDirWithinNoSymlink(baseDir, targetDir) {
2152
+ const relative = path.relative(baseDir, targetDir);
2153
+ if (path.isAbsolute(relative) || relative.split(path.sep).includes('..')) {
2154
+ throw new Error(`Bind-mount path escapes the app directory: "${targetDir}"`);
2155
+ }
2156
+ let current = baseDir;
2157
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
2158
+ current = path.join(current, segment);
2159
+ let stat;
2160
+ try {
2161
+ stat = fs.lstatSync(current);
2162
+ }
2163
+ catch {
2164
+ stat = null;
2165
+ }
2166
+ if (stat === null) {
2167
+ fs.mkdirSync(current);
2168
+ continue;
2169
+ }
2170
+ if (stat.isSymbolicLink()) {
2171
+ throw new Error(`Updated app bind-mount source must not be a symlink: "${current}"`);
2172
+ }
2173
+ if (!stat.isDirectory()) {
2174
+ throw new Error(`Updated app bind-mount path is not a directory: "${current}"`);
2175
+ }
2176
+ }
2177
+ }
2178
+ /** Reject a discovered bind path that does not stay inside both app roots. */
2179
+ isSafeBindRel(fromDir, toDir, rel) {
2180
+ if (rel.length === 0 || path.isAbsolute(rel))
2181
+ return false;
2182
+ const fromBase = fromDir.endsWith(path.sep) ? fromDir : fromDir + path.sep;
2183
+ const toBase = toDir.endsWith(path.sep) ? toDir : toDir + path.sep;
2184
+ return path.resolve(fromDir, rel).startsWith(fromBase)
2185
+ && path.resolve(toDir, rel).startsWith(toBase);
2186
+ }
2187
+ /**
2188
+ * Move app-owned relative bind data across an update directory swap.
2189
+ * Renaming preserves the database's ownership and inode, unlike copying.
2190
+ *
2191
+ * A release legitimately ships content at a bind path (a `.gitkeep`, seed
2192
+ * files, a tracked `init.sql`), so a collision is normal, not an error. Live
2193
+ * state always wins — it is the data the issue exists to protect — but a
2194
+ * directory collision is **merged** entry by entry so release-provided files
2195
+ * the previous version never had still land. Every rename performed is
2196
+ * recorded, app-relative and in order, so a rollback can replay it backwards.
2197
+ */
2198
+ moveBindMounts(fromDir, toDir, rels, job, moved) {
2199
+ for (const rel of rels) {
2200
+ if (!this.isSafeBindRel(fromDir, toDir, rel)) {
2201
+ this.log(job, `Warning: skipping bind-mount path outside the app directory: "${rel}"`);
2202
+ continue;
2203
+ }
2204
+ if (!fs.existsSync(path.resolve(fromDir, rel)))
2205
+ continue; // never created
2206
+ this.moveBindEntry(fromDir, toDir, rel, job, moved);
2207
+ }
2208
+ }
2209
+ moveBindEntry(fromDir, toDir, rel, job, moved) {
2210
+ const source = path.resolve(fromDir, rel);
2211
+ const destination = path.resolve(toDir, rel);
2212
+ this.ensureDirWithinNoSymlink(toDir, path.dirname(destination));
2213
+ let destStat;
2214
+ try {
2215
+ destStat = fs.lstatSync(destination);
2216
+ }
2217
+ catch {
2218
+ destStat = null;
2219
+ }
2220
+ if (destStat === null) {
2221
+ fs.renameSync(source, destination);
2222
+ moved?.push(rel);
2223
+ return;
2224
+ }
2225
+ if (destStat.isDirectory() && fs.lstatSync(source).isDirectory()) {
2226
+ // Merge: recurse so a release-only file inside the directory survives.
2227
+ //
2228
+ // Entry-by-entry on purpose. Swapping the trees (move the live dir over
2229
+ // wholesale, then re-apply the release's own files on top) would be one
2230
+ // rename instead of one per live file, but `moved` would no longer be an
2231
+ // exact inverse: a rollback would carry those re-applied release files
2232
+ // into the restored previous app dir. Exact rollback beats the renames,
2233
+ // which are same-filesystem and only walk a directory the release also
2234
+ // ships content in.
2235
+ for (const name of fs.readdirSync(source)) {
2236
+ this.moveBindEntry(fromDir, toDir, `${rel}/${name}`, job, moved);
2237
+ }
2238
+ return;
2239
+ }
2240
+ this.log(job, `Warning: preserved existing bind-mount data at "${rel}" — the updated release's copy of that path was discarded`);
2241
+ fs.rmSync(destination, { recursive: true, force: true });
2242
+ fs.renameSync(source, destination);
2243
+ moved?.push(rel);
2244
+ }
2245
+ /**
2246
+ * Undo {@link moveBindMounts} after a failed update: replay the recorded
2247
+ * renames backwards so the restored previous app dir gets its state back.
2248
+ */
2249
+ restoreBindMounts(fromDir, toDir, moved, job) {
2250
+ for (const rel of [...moved].reverse()) {
2251
+ if (!this.isSafeBindRel(fromDir, toDir, rel)) {
2252
+ this.log(job, `Warning: skipping bind-mount path outside the app directory: "${rel}"`);
2253
+ continue;
2254
+ }
2255
+ const source = path.resolve(fromDir, rel);
2256
+ const destination = path.resolve(toDir, rel);
2257
+ if (!fs.existsSync(source) || fs.existsSync(destination))
2258
+ continue;
2259
+ try {
2260
+ this.ensureDirWithinNoSymlink(toDir, path.dirname(destination));
2261
+ fs.renameSync(source, destination);
2262
+ }
2263
+ catch (err) {
2264
+ this.log(job, `Warning: could not restore bind-mount path "${rel}": ${err.message}`);
2265
+ }
2266
+ }
2267
+ }
1978
2268
  /**
1979
2269
  * Stop conflicting containers then run `docker compose up -d --wait`.
1980
2270
  * Captures container logs into the job on failure before rethrowing.
@@ -2076,6 +2366,104 @@ class AppInstaller {
2076
2366
  * container back down (issue #283). Returns a de-duplicated list of image
2077
2367
  * IDs, or `[]` on any error (nothing to reclaim / docker unavailable).
2078
2368
  */
2369
+ /**
2370
+ * Tag every image the app is *currently* running under a private
2371
+ * `<repo>:cg-rollback-<id>` name, and return the pairs.
2372
+ *
2373
+ * A `build:` service's new image reuses the tag of the one in production
2374
+ * (`<project>-<service>:latest`), so once the update's build succeeds that
2375
+ * tag names the new release. Rolling only the source directory back would
2376
+ * bring the app up on the failed release's image. Holding a second tag keeps
2377
+ * the old image addressable *and* alive — under the containerd image store an
2378
+ * untagged image is not kept around as a `<none>` image to find later.
2379
+ *
2380
+ * Only images this app builds are preserved: compose names them
2381
+ * `<project>-<service>`, and a pulled tag (`postgres:16-alpine`) is never
2382
+ * overwritten by a build, so it needs no protection. Best-effort throughout —
2383
+ * anything that cannot be preserved is simply absent from the result, and the
2384
+ * rollback rebuilds from the restored source instead.
2385
+ */
2386
+ preserveRunningImages(appName, dir, job) {
2387
+ let rows;
2388
+ try {
2389
+ const { stdout } = this.run(['docker', 'compose', '-p', appName, 'images', '--format', 'json'], dir, 15000);
2390
+ rows = parseJsonRows(stdout);
2391
+ }
2392
+ catch {
2393
+ return [];
2394
+ }
2395
+ const preserved = [];
2396
+ const seen = new Set();
2397
+ for (const row of rows) {
2398
+ if (typeof row !== 'object' || row === null)
2399
+ continue;
2400
+ const r = row;
2401
+ const id = typeof r.ID === 'string' ? r.ID.trim() : '';
2402
+ const repo = typeof r.Repository === 'string' ? r.Repository.trim() : '';
2403
+ const tag = typeof r.Tag === 'string' && r.Tag.trim() ? r.Tag.trim() : 'latest';
2404
+ if (!id || !repo.startsWith(`${appName}-`))
2405
+ continue;
2406
+ const ref = `${repo}:${tag}`;
2407
+ if (seen.has(ref))
2408
+ continue;
2409
+ seen.add(ref);
2410
+ const backupRef = `${repo}:${ROLLBACK_TAG_PREFIX}${id.replace(/^sha256:/, '').slice(0, 12)}`;
2411
+ try {
2412
+ this.run(['docker', 'image', 'tag', id, backupRef], dir, 15000);
2413
+ preserved.push({ ref, backupRef });
2414
+ }
2415
+ catch (err) {
2416
+ // Recorded with no backup reference: the rollback must know this tag is
2417
+ // unprotected and rebuild, rather than read an empty list as "no built
2418
+ // images, nothing at risk".
2419
+ preserved.push({ ref, backupRef: '' });
2420
+ this.log(job, `Warning: could not preserve image "${ref}" for rollback (${err instanceof Error ? err.message : String(err)})`);
2421
+ }
2422
+ }
2423
+ return preserved;
2424
+ }
2425
+ /**
2426
+ * Put each preserved image back on the reference the update's build took
2427
+ * over. Returns true when nothing is at risk (an empty list — the app builds
2428
+ * no images) or every reference is back on its original image; false when at
2429
+ * least one could not be, so the caller rebuilds from the rolled-back source
2430
+ * rather than starting the failed release's build.
2431
+ */
2432
+ restorePreservedImages(images, dir, job) {
2433
+ let allRestored = true;
2434
+ for (const { ref, backupRef } of images) {
2435
+ if (!backupRef) {
2436
+ allRestored = false;
2437
+ this.log(job, `Image "${ref}" was not preserved — rebuilding from the rolled-back source`);
2438
+ continue;
2439
+ }
2440
+ try {
2441
+ this.run(['docker', 'image', 'tag', backupRef, ref], dir, 15000);
2442
+ this.log(job, `Restored image "${ref}" to the pre-update build`);
2443
+ }
2444
+ catch (err) {
2445
+ allRestored = false;
2446
+ this.log(job, `Warning: could not restore image "${ref}" (${err instanceof Error ? err.message : String(err)}) — rebuilding from the rolled-back source instead`);
2447
+ }
2448
+ }
2449
+ return allRestored;
2450
+ }
2451
+ /**
2452
+ * Drop the private rollback tags. Removing a reference only untags the image
2453
+ * while another tag remains, so this never deletes an image the app still
2454
+ * uses. Must run before the post-update image reclaim: an extra tag would
2455
+ * make `docker image rm <id>` refuse.
2456
+ */
2457
+ dropPreservedImageTags(images) {
2458
+ for (const { backupRef } of images) {
2459
+ if (!backupRef)
2460
+ continue;
2461
+ try {
2462
+ this.run(['docker', 'image', 'rm', backupRef], os.tmpdir(), 15000);
2463
+ }
2464
+ catch { /* already gone — non-fatal */ }
2465
+ }
2466
+ }
2079
2467
  captureComposeImageIds(appName, dir) {
2080
2468
  try {
2081
2469
  const { stdout } = this.run(['docker', 'compose', '-p', appName, 'images', '--quiet'], dir, 15000);