@0xmaxma/claude-gateway 1.7.9 → 1.8.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.
Files changed (258) hide show
  1. package/README.md +178 -44
  2. package/dist/agent/builtin-commands.js +2 -2
  3. package/dist/agent/builtin-commands.js.map +1 -1
  4. package/dist/agent/dreaming/accept.d.ts.map +1 -1
  5. package/dist/agent/dreaming/accept.js +4 -1
  6. package/dist/agent/dreaming/accept.js.map +1 -1
  7. package/dist/agent/dreaming/config.d.ts.map +1 -1
  8. package/dist/agent/dreaming/config.js +2 -0
  9. package/dist/agent/dreaming/config.js.map +1 -1
  10. package/dist/agent/dreaming/reviewer.d.ts.map +1 -1
  11. package/dist/agent/dreaming/reviewer.js +9 -2
  12. package/dist/agent/dreaming/reviewer.js.map +1 -1
  13. package/dist/agent/dreaming/staleness.d.ts +7 -1
  14. package/dist/agent/dreaming/staleness.d.ts.map +1 -1
  15. package/dist/agent/dreaming/staleness.js +17 -2
  16. package/dist/agent/dreaming/staleness.js.map +1 -1
  17. package/dist/agent/dreaming/types.d.ts +17 -1
  18. package/dist/agent/dreaming/types.d.ts.map +1 -1
  19. package/dist/agent/knowledge/archive-db.d.ts +43 -0
  20. package/dist/agent/knowledge/archive-db.d.ts.map +1 -1
  21. package/dist/agent/knowledge/archive-db.js +130 -0
  22. package/dist/agent/knowledge/archive-db.js.map +1 -1
  23. package/dist/agent/knowledge/config.d.ts +3 -1
  24. package/dist/agent/knowledge/config.d.ts.map +1 -1
  25. package/dist/agent/knowledge/config.js +32 -10
  26. package/dist/agent/knowledge/config.js.map +1 -1
  27. package/dist/agent/knowledge/index.d.ts +11 -4
  28. package/dist/agent/knowledge/index.d.ts.map +1 -1
  29. package/dist/agent/knowledge/index.js +22 -1
  30. package/dist/agent/knowledge/index.js.map +1 -1
  31. package/dist/agent/knowledge/indexer.d.ts +0 -7
  32. package/dist/agent/knowledge/indexer.d.ts.map +1 -1
  33. package/dist/agent/knowledge/indexer.js +34 -7
  34. package/dist/agent/knowledge/indexer.js.map +1 -1
  35. package/dist/agent/knowledge/lifecycle.d.ts +20 -4
  36. package/dist/agent/knowledge/lifecycle.d.ts.map +1 -1
  37. package/dist/agent/knowledge/lifecycle.js +33 -4
  38. package/dist/agent/knowledge/lifecycle.js.map +1 -1
  39. package/dist/agent/knowledge/reflection.d.ts +114 -0
  40. package/dist/agent/knowledge/reflection.d.ts.map +1 -0
  41. package/dist/agent/knowledge/reflection.js +447 -0
  42. package/dist/agent/knowledge/reflection.js.map +1 -0
  43. package/dist/agent/knowledge/shared-dedup.d.ts +85 -0
  44. package/dist/agent/knowledge/shared-dedup.d.ts.map +1 -0
  45. package/dist/agent/knowledge/shared-dedup.js +187 -0
  46. package/dist/agent/knowledge/shared-dedup.js.map +1 -0
  47. package/dist/agent/knowledge/shared-promote.d.ts +82 -7
  48. package/dist/agent/knowledge/shared-promote.d.ts.map +1 -1
  49. package/dist/agent/knowledge/shared-promote.js +244 -11
  50. package/dist/agent/knowledge/shared-promote.js.map +1 -1
  51. package/dist/agent/knowledge/shared-staleness.d.ts +51 -0
  52. package/dist/agent/knowledge/shared-staleness.d.ts.map +1 -0
  53. package/dist/agent/knowledge/shared-staleness.js +149 -0
  54. package/dist/agent/knowledge/shared-staleness.js.map +1 -0
  55. package/dist/agent/knowledge/shared-writer.d.ts +19 -0
  56. package/dist/agent/knowledge/shared-writer.d.ts.map +1 -1
  57. package/dist/agent/knowledge/shared-writer.js +42 -0
  58. package/dist/agent/knowledge/shared-writer.js.map +1 -1
  59. package/dist/agent/knowledge/types.d.ts +24 -0
  60. package/dist/agent/knowledge/types.d.ts.map +1 -1
  61. package/dist/agent/model-catalog.d.ts +62 -0
  62. package/dist/agent/model-catalog.d.ts.map +1 -0
  63. package/dist/agent/model-catalog.js +264 -0
  64. package/dist/agent/model-catalog.js.map +1 -0
  65. package/dist/agent/runner.d.ts +83 -0
  66. package/dist/agent/runner.d.ts.map +1 -1
  67. package/dist/agent/runner.js +281 -51
  68. package/dist/agent/runner.js.map +1 -1
  69. package/dist/agent/workspace-loader.d.ts +17 -2
  70. package/dist/agent/workspace-loader.d.ts.map +1 -1
  71. package/dist/agent/workspace-loader.js +17 -1
  72. package/dist/agent/workspace-loader.js.map +1 -1
  73. package/dist/api/cron-router.d.ts +12 -8
  74. package/dist/api/cron-router.d.ts.map +1 -1
  75. package/dist/api/cron-router.js +82 -16
  76. package/dist/api/cron-router.js.map +1 -1
  77. package/dist/api/gateway-router.d.ts +4 -0
  78. package/dist/api/gateway-router.d.ts.map +1 -1
  79. package/dist/api/gateway-router.js +16 -1
  80. package/dist/api/gateway-router.js.map +1 -1
  81. package/dist/api/manifest-sources.d.ts +15 -0
  82. package/dist/api/manifest-sources.d.ts.map +1 -0
  83. package/dist/api/manifest-sources.js +27 -0
  84. package/dist/api/manifest-sources.js.map +1 -0
  85. package/dist/api/meta-router.d.ts +13 -0
  86. package/dist/api/meta-router.d.ts.map +1 -0
  87. package/dist/api/meta-router.js +26 -0
  88. package/dist/api/meta-router.js.map +1 -0
  89. package/dist/api/packages.d.ts.map +1 -1
  90. package/dist/api/packages.js +18 -198
  91. package/dist/api/packages.js.map +1 -1
  92. package/dist/api/route-registry.d.ts +81 -0
  93. package/dist/api/route-registry.d.ts.map +1 -0
  94. package/dist/api/route-registry.js +44 -0
  95. package/dist/api/route-registry.js.map +1 -0
  96. package/dist/api/router.d.ts.map +1 -1
  97. package/dist/api/router.js +21 -3
  98. package/dist/api/router.js.map +1 -1
  99. package/dist/apps/compose-generator.js +7 -4
  100. package/dist/apps/compose-generator.js.map +1 -1
  101. package/dist/apps/installer.d.ts +154 -0
  102. package/dist/apps/installer.d.ts.map +1 -1
  103. package/dist/apps/installer.js +657 -54
  104. package/dist/apps/installer.js.map +1 -1
  105. package/dist/cli/args.d.ts +25 -0
  106. package/dist/cli/args.d.ts.map +1 -0
  107. package/dist/cli/args.js +55 -0
  108. package/dist/cli/args.js.map +1 -0
  109. package/dist/cli/colors.d.ts +37 -0
  110. package/dist/cli/colors.d.ts.map +1 -0
  111. package/dist/cli/colors.js +63 -0
  112. package/dist/cli/colors.js.map +1 -0
  113. package/dist/cli/command-names.d.ts +98 -0
  114. package/dist/cli/command-names.d.ts.map +1 -0
  115. package/dist/cli/command-names.js +109 -0
  116. package/dist/cli/command-names.js.map +1 -0
  117. package/dist/cli/commands/agents.d.ts +28 -0
  118. package/dist/cli/commands/agents.d.ts.map +1 -0
  119. package/dist/cli/commands/agents.js +321 -0
  120. package/dist/cli/commands/agents.js.map +1 -0
  121. package/dist/cli/commands/channels.d.ts +17 -0
  122. package/dist/cli/commands/channels.d.ts.map +1 -0
  123. package/dist/cli/commands/channels.js +101 -0
  124. package/dist/cli/commands/channels.js.map +1 -0
  125. package/dist/cli/commands/debug-bundle.d.ts +6 -0
  126. package/dist/cli/commands/debug-bundle.d.ts.map +1 -0
  127. package/dist/cli/commands/debug-bundle.js +220 -0
  128. package/dist/cli/commands/debug-bundle.js.map +1 -0
  129. package/dist/cli/commands/doctor.d.ts +3 -0
  130. package/dist/cli/commands/doctor.d.ts.map +1 -0
  131. package/dist/cli/commands/doctor.js +104 -0
  132. package/dist/cli/commands/doctor.js.map +1 -0
  133. package/dist/cli/commands/gateway.d.ts +12 -0
  134. package/dist/cli/commands/gateway.d.ts.map +1 -0
  135. package/dist/cli/commands/gateway.js +145 -0
  136. package/dist/cli/commands/gateway.js.map +1 -0
  137. package/dist/cli/commands/service.d.ts +33 -0
  138. package/dist/cli/commands/service.d.ts.map +1 -0
  139. package/dist/cli/commands/service.js +513 -0
  140. package/dist/cli/commands/service.js.map +1 -0
  141. package/dist/cli/commands/update.d.ts +16 -0
  142. package/dist/cli/commands/update.d.ts.map +1 -0
  143. package/dist/cli/commands/update.js +149 -0
  144. package/dist/cli/commands/update.js.map +1 -0
  145. package/dist/cli/commands.generated.d.ts +4 -0
  146. package/dist/cli/commands.generated.d.ts.map +1 -0
  147. package/dist/cli/commands.generated.js +220 -0
  148. package/dist/cli/commands.generated.js.map +1 -0
  149. package/dist/cli/health.d.ts +31 -0
  150. package/dist/cli/health.d.ts.map +1 -0
  151. package/dist/cli/health.js +49 -0
  152. package/dist/cli/health.js.map +1 -0
  153. package/dist/cli/http-client.d.ts +140 -0
  154. package/dist/cli/http-client.d.ts.map +1 -0
  155. package/dist/cli/http-client.js +332 -0
  156. package/dist/cli/http-client.js.map +1 -0
  157. package/dist/cli/index.d.ts +27 -0
  158. package/dist/cli/index.d.ts.map +1 -0
  159. package/dist/cli/index.js +372 -0
  160. package/dist/cli/index.js.map +1 -0
  161. package/dist/cli/manager.d.ts +64 -0
  162. package/dist/cli/manager.d.ts.map +1 -0
  163. package/dist/cli/manager.js +181 -0
  164. package/dist/cli/manager.js.map +1 -0
  165. package/dist/cli/output.d.ts +49 -0
  166. package/dist/cli/output.d.ts.map +1 -0
  167. package/dist/cli/output.js +101 -0
  168. package/dist/cli/output.js.map +1 -0
  169. package/dist/cli/prompt.d.ts +29 -0
  170. package/dist/cli/prompt.d.ts.map +1 -0
  171. package/dist/cli/prompt.js +154 -0
  172. package/dist/cli/prompt.js.map +1 -0
  173. package/dist/cli/redact.d.ts +3 -0
  174. package/dist/cli/redact.d.ts.map +1 -0
  175. package/dist/cli/redact.js +33 -0
  176. package/dist/cli/redact.js.map +1 -0
  177. package/dist/cli/types.d.ts +22 -0
  178. package/dist/cli/types.d.ts.map +1 -0
  179. package/dist/cli/types.js +4 -0
  180. package/dist/cli/types.js.map +1 -0
  181. package/dist/config/bootstrap.d.ts +33 -0
  182. package/dist/config/bootstrap.d.ts.map +1 -0
  183. package/dist/config/bootstrap.js +117 -0
  184. package/dist/config/bootstrap.js.map +1 -0
  185. package/dist/config/loader.d.ts.map +1 -1
  186. package/dist/config/loader.js +5 -1
  187. package/dist/config/loader.js.map +1 -1
  188. package/dist/config/watcher.d.ts.map +1 -1
  189. package/dist/config/watcher.js +2 -35
  190. package/dist/config/watcher.js.map +1 -1
  191. package/dist/discord/receiver.d.ts +6 -1
  192. package/dist/discord/receiver.d.ts.map +1 -1
  193. package/dist/discord/receiver.js +9 -1
  194. package/dist/discord/receiver.js.map +1 -1
  195. package/dist/entry.d.ts +3 -0
  196. package/dist/entry.d.ts.map +1 -0
  197. package/dist/entry.js +83 -0
  198. package/dist/entry.js.map +1 -0
  199. package/dist/index.js +197 -47
  200. package/dist/index.js.map +1 -1
  201. package/dist/load-dotenv.d.ts +2 -0
  202. package/dist/load-dotenv.d.ts.map +1 -0
  203. package/dist/load-dotenv.js +68 -0
  204. package/dist/load-dotenv.js.map +1 -0
  205. package/dist/packages/registry.d.ts +50 -0
  206. package/dist/packages/registry.d.ts.map +1 -0
  207. package/dist/packages/registry.js +209 -0
  208. package/dist/packages/registry.js.map +1 -0
  209. package/dist/session/process.d.ts +20 -3
  210. package/dist/session/process.d.ts.map +1 -1
  211. package/dist/session/process.js +120 -5
  212. package/dist/session/process.js.map +1 -1
  213. package/dist/shell/claude-pty-shell.js +146 -27
  214. package/dist/shell/claude-pty-shell.js.map +1 -1
  215. package/dist/shell/draft-phantom.d.ts +49 -0
  216. package/dist/shell/draft-phantom.d.ts.map +1 -0
  217. package/dist/shell/draft-phantom.js +59 -0
  218. package/dist/shell/draft-phantom.js.map +1 -0
  219. package/dist/shell/submit-diag.d.ts +67 -0
  220. package/dist/shell/submit-diag.d.ts.map +1 -0
  221. package/dist/shell/submit-diag.js +69 -0
  222. package/dist/shell/submit-diag.js.map +1 -0
  223. package/dist/shutdown-signals.d.ts +47 -0
  224. package/dist/shutdown-signals.d.ts.map +1 -0
  225. package/dist/shutdown-signals.js +62 -0
  226. package/dist/shutdown-signals.js.map +1 -0
  227. package/dist/telegram/receiver.d.ts +6 -1
  228. package/dist/telegram/receiver.d.ts.map +1 -1
  229. package/dist/telegram/receiver.js +9 -1
  230. package/dist/telegram/receiver.js.map +1 -1
  231. package/dist/types.d.ts +25 -0
  232. package/dist/types.d.ts.map +1 -1
  233. package/dist/utils/orphan-receivers.d.ts +90 -0
  234. package/dist/utils/orphan-receivers.d.ts.map +1 -0
  235. package/dist/utils/orphan-receivers.js +218 -0
  236. package/dist/utils/orphan-receivers.js.map +1 -0
  237. package/dist/utils/paths.d.ts +16 -0
  238. package/dist/utils/paths.d.ts.map +1 -0
  239. package/dist/utils/paths.js +60 -0
  240. package/dist/utils/paths.js.map +1 -0
  241. package/dist/utils/stop-child.d.ts +21 -0
  242. package/dist/utils/stop-child.d.ts.map +1 -0
  243. package/dist/utils/stop-child.js +64 -0
  244. package/dist/utils/stop-child.js.map +1 -0
  245. package/lib/pairing.ts +1 -1
  246. package/mcp/tools/agent/handlers.ts +1 -1
  247. package/mcp/tools/discord/module.ts +23 -15
  248. package/mcp/tools/discord/receiver-server.ts +4 -0
  249. package/mcp/tools/memory/archive-reader.test.ts +25 -1
  250. package/mcp/tools/memory/archive-reader.ts +70 -12
  251. package/mcp/tools/memory/archive-writer.test.ts +168 -0
  252. package/mcp/tools/memory/archive-writer.ts +137 -0
  253. package/mcp/tools/memory/module.test.ts +187 -0
  254. package/mcp/tools/memory/module.ts +192 -6
  255. package/mcp/tools/telegram/module.ts +10 -2
  256. package/mcp/tools/telegram/receiver-server.ts +15 -6
  257. package/mcp/tools/telegram/typing.ts +178 -34
  258. package/package.json +6 -4
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.AppInstaller = void 0;
37
+ exports.isPermissionError = isPermissionError;
38
+ exports.commonAncestorDir = commonAncestorDir;
37
39
  exports.parseComposePs = parseComposePs;
38
40
  exports.mapContainerStatesToAppStatus = mapContainerStatesToAppStatus;
39
41
  const fs = __importStar(require("node:fs"));
@@ -65,6 +67,10 @@ const VOLUME_TAR_TIMEOUT_MS = 300000;
65
67
  // OCI image used for the throwaway tar helper. Small, ubiquitous, already a
66
68
  // transitive dependency of most stacks, so it is almost always cache-warm.
67
69
  const BACKUP_HELPER_IMAGE = 'alpine';
70
+ // Ceiling for the root helper that moves a bind path the gateway user cannot
71
+ // rename itself. The move is a single rename(2) — instant — so this only bounds
72
+ // a stuck container (or a cold pull of the helper image) during an update swap.
73
+ const BIND_MOVE_TIMEOUT_MS = 120000;
68
74
  // Per-app ceiling for the boot-time `compose up --wait` during restore. Runs in
69
75
  // the background (non-blocking), so this only bounds how long a hung container
70
76
  // keeps its child process alive — not the gateway's responsiveness. Shorter than
@@ -96,6 +102,90 @@ function isValidTimezone(tz) {
96
102
  // Disallow '..' in owner/repo segments — prevents path traversal via edge-case git URL parsing.
97
103
  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
104
  // ─── Installer ────────────────────────────────────────────────────────────────
105
+ const UUID_RE_SRC = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}';
106
+ /**
107
+ * The staging checkout `runUpdate` creates beside an app's install path,
108
+ * `.cg-update-<app>-<uuid>`. It holds a fresh clone and nothing else, so a
109
+ * crash leftover is pure garbage and safe to reclaim. The UUID means the
110
+ * pattern cannot match an app directory named by a user.
111
+ */
112
+ const STALE_UPDATE_DIR_RE = new RegExp(`^\\.cg-update-.+-${UUID_RE_SRC}$`);
113
+ /**
114
+ * The release snapshots `runUpdate` renames an install path to during the swap:
115
+ * `<appDir>-old-<uuid>` (previous release) and `<appDir>-failed-<uuid>` (the
116
+ * release that failed). Deliberately **not** swept.
117
+ *
118
+ * Both can hold the only copy of live bind-mount data. `-failed-` is kept on
119
+ * purpose when a rollback cannot move a bind path back, and a crash between the
120
+ * swap's two renames leaves an `-old-` still holding the paths that had not
121
+ * moved yet. Sweeping them would delete a database — through {@link rmrf}'s
122
+ * `sudo rm -rf` fallback, which exists precisely to defeat the container
123
+ * ownership that made the data unmovable in the first place. They are reported
124
+ * at boot instead, so a leak is visible rather than silent.
125
+ */
126
+ const RELEASE_SNAPSHOT_DIR_RE = new RegExp(`-(?:old|failed)-${UUID_RE_SRC}$`);
127
+ /**
128
+ * A filesystem call that failed because the gateway user lacks the rights, not
129
+ * because the path is wrong. Containers running as their image's uid leave
130
+ * files and directories the gateway user can neither delete nor rename, and
131
+ * those are the two codes the kernel reports for it.
132
+ */
133
+ function isPermissionError(err) {
134
+ const code = err?.code;
135
+ return code === 'EACCES' || code === 'EPERM';
136
+ }
137
+ /**
138
+ * Deepest directory containing both paths, never either path itself.
139
+ *
140
+ * Used to pick a **single** mount for the root helper that moves a bind path:
141
+ * mounting each parent separately would put the two paths on different mount
142
+ * points, where `rename(2)` fails EXDEV and `mv` degrades to a copy.
143
+ *
144
+ * The comparison stops one component short of the shorter path, so the result
145
+ * is always a strict ancestor of both — for siblings (what the callers here
146
+ * pass) that is exact. When one path contains the other it is the *shallower*
147
+ * path's parent, i.e. broader than strictly needed; never narrower, which would
148
+ * put a path outside the mount.
149
+ */
150
+ function commonAncestorDir(a, b) {
151
+ const left = path.resolve(a).split(path.sep);
152
+ const right = path.resolve(b).split(path.sep);
153
+ const shared = [];
154
+ for (let i = 0; i < Math.min(left.length, right.length) - 1; i++) {
155
+ if (left[i] !== right[i])
156
+ break;
157
+ shared.push(left[i]);
158
+ }
159
+ return shared.join(path.sep) || path.sep;
160
+ }
161
+ /**
162
+ * Rows from a `docker compose … --format json` call. Compose has emitted both a
163
+ * single JSON array and one object per line across its 2.x line, so accept
164
+ * either rather than silently reading a newer/older daemon as "nothing here".
165
+ */
166
+ function parseJsonRows(stdout) {
167
+ const text = stdout.trim();
168
+ if (!text)
169
+ return [];
170
+ try {
171
+ const parsed = JSON.parse(text);
172
+ return Array.isArray(parsed) ? parsed : [parsed];
173
+ }
174
+ catch { /* not a single document — try line-delimited below */ }
175
+ const rows = [];
176
+ for (const line of text.split('\n')) {
177
+ const trimmed = line.trim();
178
+ if (!trimmed)
179
+ continue;
180
+ try {
181
+ rows.push(JSON.parse(trimmed));
182
+ }
183
+ catch { /* skip a line that is not JSON */ }
184
+ }
185
+ return rows;
186
+ }
187
+ /** Private tag the update holds on an image so a rollback can put it back. */
188
+ const ROLLBACK_TAG_PREFIX = 'cg-rollback-';
99
189
  class AppInstaller {
100
190
  constructor(registry, registryClient, callbacks, spawn = defaultSpawn, appsDir, agentManager, spawnAsync = defaultAsyncSpawn, housekeepingConfig = {}, appBackupConfig, backupsDir) {
101
191
  this.registry = registry;
@@ -720,23 +810,29 @@ class AppInstaller {
720
810
  * read-only `~/.claude/projects`) are intentionally excluded. Best-effort:
721
811
  * returns `[]` on any failure, mirroring {@link discoverVolumes}.
722
812
  */
723
- discoverBindMounts(appName, appDir) {
813
+ /**
814
+ * The paths a generated compose file may have resolved this app dir to.
815
+ * Local-dev installs symlink the app dir into appsDir, and the generated
816
+ * compose resolves bind sources against the symlink's *realpath*, so both the
817
+ * symlink path and its target must be treated as the app root.
818
+ */
819
+ bindMountBases(appDir) {
820
+ const bases = [appDir];
821
+ try {
822
+ const real = fs.realpathSync(appDir);
823
+ if (real !== appDir)
824
+ bases.push(real);
825
+ }
826
+ catch {
827
+ /* app dir unreadable — fall back to the literal path */
828
+ }
829
+ return bases;
830
+ }
831
+ discoverBindMounts(appName, appDir, onError = 'empty') {
832
+ const bases = this.bindMountBases(appDir);
724
833
  try {
725
834
  const { stdout } = this.run(['docker', 'compose', '-p', appName, 'config', '--format', 'json'], appDir, 30000);
726
835
  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
836
  const rels = new Set();
741
837
  for (const svc of Object.values(parsed.services ?? {})) {
742
838
  for (const vol of svc.volumes ?? []) {
@@ -753,8 +849,18 @@ class AppInstaller {
753
849
  }
754
850
  return Array.from(rels).sort();
755
851
  }
756
- catch {
757
- return [];
852
+ catch (err) {
853
+ if (onError === 'empty')
854
+ return [];
855
+ // Fail closed: the caller is about to move this app's directory, so an
856
+ // unknown bind set would silently strand live state. Only an app whose
857
+ // stored compose anchors nothing to the app dir (under either base) is
858
+ // safe to treat as bind-free.
859
+ const composePath = path.join(appDir, 'docker-compose.yml');
860
+ const compose = fs.existsSync(composePath) ? fs.readFileSync(composePath, 'utf-8') : '';
861
+ if (!bases.some((b) => compose.includes(b)))
862
+ return [];
863
+ throw new Error(`Cannot safely discover bind mounts for update: ${err.message}`);
758
864
  }
759
865
  }
760
866
  /**
@@ -823,6 +929,60 @@ class AppInstaller {
823
929
  * Returns a cancel function. No-op (returns a noop canceller) when both caps
824
930
  * are disabled. The timer is `unref`'d so it never holds the event loop open.
825
931
  */
932
+ /**
933
+ * Reclaim the `.cg-update-*` staging checkout a crashed or killed update left
934
+ * behind. Staging moved next to the install path so the swap is a
935
+ * same-filesystem rename, which also means `/tmp` cleanup no longer collects
936
+ * it — without this sweep a mid-update crash leaks a full app checkout
937
+ * forever.
938
+ *
939
+ * Release snapshots (`-old-`/`-failed-`) are **reported, never removed**: see
940
+ * {@link RELEASE_SNAPSHOT_DIR_RE}. They can hold the only copy of a live bind
941
+ * mount, and this sweep deletes with root.
942
+ *
943
+ * **Boot only.** An update in flight owns directories matching these names,
944
+ * so this must run before any update can start. Never throws; a directory it
945
+ * cannot remove is reported and skipped.
946
+ */
947
+ async sweepStaleUpdateDirs() {
948
+ const swept = [];
949
+ let names;
950
+ try {
951
+ names = fs.readdirSync(this.appsDir);
952
+ }
953
+ catch {
954
+ return swept; // no apps dir yet
955
+ }
956
+ // An installPath is authoritative — never remove a directory an app still
957
+ // points at, however its name happens to look.
958
+ let live;
959
+ try {
960
+ live = new Set((await this.registry.list()).map((a) => a.installPath));
961
+ }
962
+ catch {
963
+ return swept; // registry unreadable — do not guess
964
+ }
965
+ for (const name of names) {
966
+ const full = path.join(this.appsDir, name);
967
+ if (live.has(full))
968
+ continue;
969
+ if (RELEASE_SNAPSHOT_DIR_RE.test(name)) {
970
+ console.warn(`[installer] keeping app release snapshot "${full}" — it may hold the only copy of a `
971
+ + 'bind mount an update could not move back. Recover or delete it by hand.');
972
+ continue;
973
+ }
974
+ if (!STALE_UPDATE_DIR_RE.test(name))
975
+ continue;
976
+ try {
977
+ this.rmrf(full);
978
+ swept.push(full);
979
+ }
980
+ catch (err) {
981
+ console.warn(`[installer] failed to sweep stale update dir "${full}": ${err.message}`);
982
+ }
983
+ }
984
+ return swept;
985
+ }
826
986
  startBackupCleanup() {
827
987
  const { retention, maxAgeDays, cleanupHour, cleanupTimezone } = this.appBackupConfig;
828
988
  if (retention <= 0 && maxAgeDays <= 0)
@@ -1357,7 +1517,12 @@ class AppInstaller {
1357
1517
  this.log(job, `WARNING: pre-update backup failed (continuing): ${err instanceof Error ? err.message : String(err)}`);
1358
1518
  }
1359
1519
  }
1360
- const tmpDir = path.join(os.tmpdir(), `cg-update-${appName}-${crypto.randomUUID()}`);
1520
+ // Stage beside the durable install path so the directory swap and bind-data
1521
+ // moves are same-filesystem renames, not cross-device copies/failures.
1522
+ const tmpDir = path.join(path.dirname(entry.installPath), `.cg-update-${appName}-${crypto.randomUUID()}`);
1523
+ // Rollback tags held on the images this update is about to build over.
1524
+ // Declared out here so every exit path can drop them again.
1525
+ let preservedImages = [];
1361
1526
  try {
1362
1527
  // ── Shallow fetch of specific commit into tmp dir ─────────────────────
1363
1528
  this.log(job, `Cloning ${target.repo}`);
@@ -1392,12 +1557,20 @@ class AppInstaller {
1392
1557
  version: newVersion,
1393
1558
  commit: target.newCommit,
1394
1559
  installPath: tmpDir,
1395
- ...(generated.agentDeclaration !== null ? { agentDeclaration: generated.agentDeclaration } : {}),
1560
+ agentDeclaration: generated.agentDeclaration,
1396
1561
  ...(agentPaths ? { agentPaths } : {}),
1397
1562
  };
1398
1563
  if (generated.agentDeclaration && this.agentManager && agentPaths) {
1399
1564
  this.agentManager.injectAgentService(newEntry);
1400
1565
  }
1566
+ // Pin the images the app is running before the build takes their tags
1567
+ // over. A `build:` service's new image reuses the old tag
1568
+ // (`<project>-<service>`), so once the build succeeds that tag names the
1569
+ // new release: rolling only the source back would bring the app up on the
1570
+ // failed release's image and crash-loop on a "successful" rollback. This
1571
+ // has to happen before the build — afterwards the old image has no
1572
+ // reference left to grab it by.
1573
+ preservedImages = this.preserveRunningImages(appName, entry.installPath, job);
1401
1574
  // ── Build new images in tmp dir ───────────────────────────────────────
1402
1575
  this.log(job, 'Building new images');
1403
1576
  this.run(['docker', 'compose', '-p', appName, 'build'], tmpDir, 600000);
@@ -1409,30 +1582,99 @@ class AppInstaller {
1409
1582
  this.log(job, 'MEMORY.md backed up');
1410
1583
  }
1411
1584
  }
1585
+ // Discover the app-owned bind paths while the old compose file still points
1586
+ // at the durable install directory. They must move with the release swap so
1587
+ // cleanup cannot delete live state. This query fails closed, so it runs
1588
+ // *before* routes and sockets come down — a throw here would otherwise
1589
+ // leave the app running but unreachable, with no path back.
1590
+ const oldBindMounts = this.discoverBindMounts(appName, entry.installPath, 'throw');
1412
1591
  // ── Deregister old routes before taking down containers ───────────────
1413
1592
  this.callbacks.deregisterRoutes(appName);
1414
1593
  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).
1594
+ // Capture the current (old) image IDs while the old compose file is still
1595
+ // the live one, so the reclaim below targets exactly this app's images.
1418
1596
  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
1597
  const oldImageRefs = this.captureComposeImageRefs(appName, entry.installPath);
1422
- // ── Bring old containers down (keeps images for rollback) ─────────────
1598
+ // ── Swap dirs before starting the new containers ───────────────────────
1599
+ // The new compose file's bind sources are anchored to finalDir. Starting
1600
+ // before this swap would bind the old directory inode then delete it.
1423
1601
  this.log(job, 'Stopping old containers');
1424
1602
  this.run(['docker', 'compose', '-p', appName, 'down'], entry.installPath, 120000);
1425
- // ── Start new containers ──────────────────────────────────────────────
1426
- this.log(job, 'Starting new containers');
1603
+ this.log(job, 'Swapping app directories');
1604
+ const finalDir = entry.installPath;
1605
+ const oldBackupDir = `${finalDir}-old-${crypto.randomUUID()}`;
1606
+ let swapped = false;
1607
+ let reachedContainerStart = false;
1608
+ let failedDir = null;
1609
+ const movedBindMounts = [];
1427
1610
  try {
1428
- this.composeUp(appName, tmpDir, job);
1611
+ fs.renameSync(finalDir, oldBackupDir);
1612
+ fs.renameSync(tmpDir, finalDir);
1613
+ swapped = true;
1614
+ this.moveBindMounts(oldBackupDir, finalDir, oldBindMounts, job, movedBindMounts);
1615
+ // The source checkout is now permanent. Rewrite the compose file with
1616
+ // finalDir as its base so every bind source and build context resolves
1617
+ // to the durable path — this call is kept for that side effect; its
1618
+ // return value necessarily matches `generated` (same app.yaml). The
1619
+ // rewrite drops the injected agent service, so re-inject it after.
1620
+ const finalComposePath = path.join(finalDir, 'docker-compose.yml');
1621
+ const finalYaml = (0, compose_generator_1.parseAppYaml)(fs.readFileSync(path.join(finalDir, 'app.yaml'), 'utf-8'), finalDir);
1622
+ (0, compose_generator_1.generateCompose)(finalYaml, appName, finalDir, finalComposePath);
1623
+ if (generated.agentDeclaration && this.agentManager && agentPaths) {
1624
+ this.agentManager.injectAgentService({ ...newEntry, installPath: finalDir });
1625
+ }
1626
+ // ── Start new containers ────────────────────────────────────────────
1627
+ this.log(job, 'Starting new containers');
1628
+ reachedContainerStart = true;
1629
+ this.composeUp(appName, finalDir, job);
1429
1630
  }
1430
1631
  catch (upErr) {
1431
- // Rollback: bring old containers back up from old install path
1432
- this.log(job, 'New containers failed — rolling back to previous version');
1632
+ // The catch spans the whole swap, so a failure here is not necessarily a
1633
+ // container failure — saying it is sends diagnosis to the wrong
1634
+ // subsystem (a bind-mount move that threw looked exactly like a crashed
1635
+ // container in the job log).
1636
+ this.log(job, reachedContainerStart
1637
+ ? 'New containers failed — rolling back to previous version'
1638
+ : `Update failed during the directory swap — rolling back to previous version: ${upErr.message}`);
1433
1639
  let rollbackFailed = false;
1640
+ failedDir = `${finalDir}-failed-${crypto.randomUUID()}`;
1434
1641
  try {
1435
- this.run(['docker', 'compose', '-p', appName, 'up', '-d'], entry.installPath, 120000);
1642
+ let unrestored = [];
1643
+ if (swapped) {
1644
+ fs.renameSync(finalDir, failedDir);
1645
+ fs.renameSync(oldBackupDir, finalDir);
1646
+ unrestored = this.restoreBindMounts(failedDir, finalDir, movedBindMounts, job);
1647
+ }
1648
+ else if (fs.existsSync(oldBackupDir)) {
1649
+ fs.renameSync(oldBackupDir, finalDir);
1650
+ }
1651
+ // Point every tag back at the image the app was actually running
1652
+ // before this update built over it, so the restored source and the
1653
+ // restored image are the same release. Falls back to rebuilding from
1654
+ // the restored source when an old image is no longer on disk.
1655
+ //
1656
+ // This has to happen even when the bind restore below refuses to
1657
+ // start the app: the source is already back on the previous release,
1658
+ // so `<app>-<service>:latest` must name that release's build before
1659
+ // *anything* starts it. Leaving it on the failed release's build
1660
+ // means an operator who finishes the recovery by hand brings old
1661
+ // source up on a new image — the crash-loop `preserveRunningImages`
1662
+ // exists to prevent.
1663
+ const rebuild = !this.restorePreservedImages(preservedImages, finalDir, job);
1664
+ if (unrestored.length > 0) {
1665
+ // Throwing here is deliberate: it skips both the container start
1666
+ // below and the safeRmrf of failedDir, so the only copy of that
1667
+ // data stays on disk. Starting the app on a half-restored
1668
+ // directory is worse than not starting it — postgres on an empty
1669
+ // pgdata initialises a fresh cluster and then looks healthy.
1670
+ throw new Error(`live bind-mount data for ${unrestored.map((r) => `"${r}"`).join(', ')} could not be moved back `
1671
+ + `— it is still in "${failedDir}", which has been kept. Move those paths back into `
1672
+ + `"${finalDir}" before starting the app.`);
1673
+ }
1674
+ const upArgs = ['docker', 'compose', '-p', appName, 'up', '-d'];
1675
+ if (rebuild)
1676
+ upArgs.push('--build');
1677
+ this.run(upArgs, finalDir, rebuild ? 600000 : 120000);
1436
1678
  this.callbacks.registerRoutes(appName, entry.ports.map((p) => ({
1437
1679
  name: p.name,
1438
1680
  service: p.service,
@@ -1442,38 +1684,41 @@ class AppInstaller {
1442
1684
  rateLimit: p.rateLimit,
1443
1685
  })));
1444
1686
  await this.registry.updateStatus(appName, 'running');
1687
+ if (failedDir !== null)
1688
+ this.safeRmrf(failedDir, job, 'failed update dir');
1445
1689
  }
1446
1690
  catch (rollbackErr) {
1447
1691
  rollbackFailed = true;
1448
1692
  this.log(job, `ROLLBACK FAILED — app "${appName}" may be in a broken state: ${rollbackErr.message}`);
1449
1693
  }
1450
- this.safeRmrf(tmpDir, job, 'update temp dir');
1451
1694
  if (rollbackFailed) {
1695
+ // Keep the private `cg-rollback-*` tags: they are the only remaining
1696
+ // reference to the pre-update build, and the outer catch drops them.
1697
+ // Clearing the list here is what stops that — an incomplete rollback
1698
+ // is finished by hand, and that recovery needs the old image to still
1699
+ // exist. Untagged, containerd is free to reclaim it.
1700
+ if (preservedImages.some((i) => i.backupRef)) {
1701
+ this.log(job, `Pre-update images kept for manual recovery: ${preservedImages.filter((i) => i.backupRef).map((i) => `"${i.backupRef}" (for "${i.ref}")`).join(', ')}`);
1702
+ }
1703
+ preservedImages = [];
1704
+ // The success path above sets 'running'. Leaving the registry on the
1705
+ // pre-update 'running' here would report a healthy app that is not
1706
+ // running at all — and this branch now includes the case where the
1707
+ // app was deliberately not restarted.
1708
+ await this.registry.updateStatus(appName, 'error').catch(() => { });
1452
1709
  throw new Error(`Update failed and rollback also failed — app "${appName}" may be in a broken state. Check job logs for details.`);
1453
1710
  }
1454
1711
  throw upErr;
1455
1712
  }
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
- }
1713
+ // The new stack is up: the previous images are no longer a rollback
1714
+ // target. Untag them before the reclaim below, which removes by image ID
1715
+ // and would be refused while a second reference exists.
1716
+ this.dropPreservedImageTags(preservedImages);
1717
+ preservedImages = [];
1718
+ // Capture the new stack's image IDs from its permanent compose file so
1719
+ // cleanup below never removes an image the new containers depend on.
1720
+ const newImageIds = this.captureComposeImageIds(appName, finalDir);
1721
+ const newImageRefs = this.captureComposeImageRefs(appName, finalDir);
1477
1722
  // ── Update registry ───────────────────────────────────────────────────
1478
1723
  const finalEntry = {
1479
1724
  ...newEntry,
@@ -1482,10 +1727,28 @@ class AppInstaller {
1482
1727
  status: 'running',
1483
1728
  };
1484
1729
  await this.registry.upsert(finalEntry);
1485
- // ── Re-create agent symlink + config.json entry ───────────────────────
1730
+ // ── Re-create, rename, or remove the app-agent registration ───────────
1731
+ // `upsertAgent` keys off the *new* agent name, so a release that drops or
1732
+ // renames its agent would otherwise strand the previous workspace symlink
1733
+ // and config.json entry. Both transitions are the same deregistration.
1734
+ const oldAgentName = entry.agentDeclaration?.name ?? null;
1735
+ const newAgentName = generated.agentDeclaration?.name ?? null;
1736
+ if (this.agentManager && oldAgentName !== null && oldAgentName !== newAgentName) {
1737
+ await this.agentManager.deleteAgentByName(oldAgentName);
1738
+ this.log(job, newAgentName === null
1739
+ ? `Agent "${oldAgentName}" removed`
1740
+ : `Agent "${oldAgentName}" deregistered (renamed to "${newAgentName}")`);
1741
+ }
1486
1742
  if (generated.agentDeclaration && this.agentManager) {
1487
1743
  await this.agentManager.upsertAgent(finalEntry);
1488
1744
  this.log(job, `Agent "${generated.agentDeclaration.name}" re-registered`);
1745
+ // MEMORY.md must be written *after* registration: restoreMemory resolves
1746
+ // the workspace through config.json, so on a rename the new name is not
1747
+ // resolvable until upsertAgent has written its entry.
1748
+ if (memoryBackup !== null) {
1749
+ this.agentManager.restoreMemory(generated.agentDeclaration.name, memoryBackup);
1750
+ this.log(job, 'MEMORY.md restored');
1751
+ }
1489
1752
  await this.callbacks.reinitializeAgent?.(generated.agentDeclaration.name);
1490
1753
  }
1491
1754
  // ── Re-register proxy routes + sockets ───────────────────────────────
@@ -1534,6 +1797,7 @@ class AppInstaller {
1534
1797
  this.log(job, `Update complete → ${newVersion}`);
1535
1798
  }
1536
1799
  catch (err) {
1800
+ this.dropPreservedImageTags(preservedImages);
1537
1801
  if (fs.existsSync(tmpDir)) {
1538
1802
  this.safeRmrf(tmpDir, job, 'update temp dir');
1539
1803
  }
@@ -1950,7 +2214,7 @@ class AppInstaller {
1950
2214
  const code = err.code;
1951
2215
  // Containers running as root leave root-owned files (e.g. postgres pgdata)
1952
2216
  // that the gateway user cannot delete — fs.rmSync surfaces EACCES or EPERM.
1953
- if (code === 'EACCES' || code === 'EPERM') {
2217
+ if (isPermissionError(err)) {
1954
2218
  console.warn(`[installer] ${code} removing "${dirPath}" — falling back to sudo rm -rf`);
1955
2219
  this.run(['sudo', 'rm', '-rf', dirPath]);
1956
2220
  }
@@ -1975,6 +2239,247 @@ class AppInstaller {
1975
2239
  }
1976
2240
  }
1977
2241
  }
2242
+ /**
2243
+ * Create `targetDir` under `baseDir`, refusing to traverse or create through a
2244
+ * symlink. The check runs **before** each segment is created — a `mkdir -p`
2245
+ * that ran first would already have materialised directories on the far side
2246
+ * of a symlink, leaving the guard with nothing left to prevent.
2247
+ *
2248
+ * Scope: this guards the **destination** side — the freshly checked-out
2249
+ * release, which is the side an app repo controls. A bind path that was
2250
+ * already a symlink in the *previous* app dir is carried across as-is; that
2251
+ * preserves an escape the operator set up themselves rather than creating
2252
+ * one, and is the behaviour every release before this one had.
2253
+ */
2254
+ ensureDirWithinNoSymlink(baseDir, targetDir) {
2255
+ const relative = path.relative(baseDir, targetDir);
2256
+ if (path.isAbsolute(relative) || relative.split(path.sep).includes('..')) {
2257
+ throw new Error(`Bind-mount path escapes the app directory: "${targetDir}"`);
2258
+ }
2259
+ let current = baseDir;
2260
+ for (const segment of relative.split(path.sep).filter(Boolean)) {
2261
+ current = path.join(current, segment);
2262
+ let stat;
2263
+ try {
2264
+ stat = fs.lstatSync(current);
2265
+ }
2266
+ catch {
2267
+ stat = null;
2268
+ }
2269
+ if (stat === null) {
2270
+ fs.mkdirSync(current);
2271
+ continue;
2272
+ }
2273
+ if (stat.isSymbolicLink()) {
2274
+ throw new Error(`Updated app bind-mount source must not be a symlink: "${current}"`);
2275
+ }
2276
+ if (!stat.isDirectory()) {
2277
+ throw new Error(`Updated app bind-mount path is not a directory: "${current}"`);
2278
+ }
2279
+ }
2280
+ }
2281
+ /** Reject a discovered bind path that does not stay inside both app roots. */
2282
+ isSafeBindRel(fromDir, toDir, rel) {
2283
+ if (rel.length === 0 || path.isAbsolute(rel))
2284
+ return false;
2285
+ const fromBase = fromDir.endsWith(path.sep) ? fromDir : fromDir + path.sep;
2286
+ const toBase = toDir.endsWith(path.sep) ? toDir : toDir + path.sep;
2287
+ return path.resolve(fromDir, rel).startsWith(fromBase)
2288
+ && path.resolve(toDir, rel).startsWith(toBase);
2289
+ }
2290
+ /**
2291
+ * Move app-owned relative bind data across an update directory swap.
2292
+ * Renaming preserves the database's ownership and inode, unlike copying.
2293
+ *
2294
+ * A release legitimately ships content at a bind path (a `.gitkeep`, seed
2295
+ * files, a tracked `init.sql`), so a collision is normal, not an error. Live
2296
+ * state always wins — it is the data the issue exists to protect — but a
2297
+ * directory collision is **merged** entry by entry so release-provided files
2298
+ * the previous version never had still land. Every rename performed is
2299
+ * recorded, app-relative and in order, so a rollback can replay it backwards.
2300
+ */
2301
+ moveBindMounts(fromDir, toDir, rels, job, moved) {
2302
+ for (const rel of rels) {
2303
+ if (!this.isSafeBindRel(fromDir, toDir, rel)) {
2304
+ this.log(job, `Warning: skipping bind-mount path outside the app directory: "${rel}"`);
2305
+ continue;
2306
+ }
2307
+ if (!fs.existsSync(path.resolve(fromDir, rel)))
2308
+ continue; // never created
2309
+ this.moveBindEntry(fromDir, toDir, rel, job, moved);
2310
+ }
2311
+ }
2312
+ moveBindEntry(fromDir, toDir, rel, job, moved) {
2313
+ const source = path.resolve(fromDir, rel);
2314
+ const destination = path.resolve(toDir, rel);
2315
+ this.ensureDirWithinNoSymlink(toDir, path.dirname(destination));
2316
+ let destStat;
2317
+ try {
2318
+ destStat = fs.lstatSync(destination);
2319
+ }
2320
+ catch {
2321
+ destStat = null;
2322
+ }
2323
+ if (destStat === null) {
2324
+ this.moveBindPath(source, destination, rel, job);
2325
+ moved?.push(rel);
2326
+ return;
2327
+ }
2328
+ if (destStat.isDirectory() && fs.lstatSync(source).isDirectory()) {
2329
+ // Merge: recurse so a release-only file inside the directory survives.
2330
+ //
2331
+ // Entry-by-entry on purpose. Swapping the trees (move the live dir over
2332
+ // wholesale, then re-apply the release's own files on top) would be one
2333
+ // rename instead of one per live file, but `moved` would no longer be an
2334
+ // exact inverse: a rollback would carry those re-applied release files
2335
+ // into the restored previous app dir. Exact rollback beats the renames,
2336
+ // which are same-filesystem and only walk a directory the release also
2337
+ // ships content in.
2338
+ let entries;
2339
+ try {
2340
+ entries = fs.readdirSync(source);
2341
+ }
2342
+ catch (err) {
2343
+ // The live directory is one the app's container owns and the gateway
2344
+ // user cannot even list (postgres leaves pgdata 0700). There is no way
2345
+ // to merge into it entry by entry, so preserve it wholesale — the same
2346
+ // trade the non-directory collision below already makes.
2347
+ if (!isPermissionError(err))
2348
+ throw err;
2349
+ entries = null;
2350
+ }
2351
+ if (entries !== null) {
2352
+ // Each child that needs the root helper costs its own container. That
2353
+ // is bounded by the live directory's own entry count, and only reached
2354
+ // when a release ships tracked content *inside* a path whose children
2355
+ // the gateway user cannot rename — rare enough not to trade the exact
2356
+ // rollback above for one wholesale move.
2357
+ for (const name of entries) {
2358
+ this.moveBindEntry(fromDir, toDir, `${rel}/${name}`, job, moved);
2359
+ }
2360
+ return;
2361
+ }
2362
+ }
2363
+ this.log(job, `Warning: preserved existing bind-mount data at "${rel}" — the updated release's copy of that path `
2364
+ + `was discarded (${this.describeDiscarded(destination)})`);
2365
+ fs.rmSync(destination, { recursive: true, force: true });
2366
+ this.moveBindPath(source, destination, rel, job);
2367
+ moved?.push(rel);
2368
+ }
2369
+ /**
2370
+ * Name what a preserve-the-live-copy collision is about to delete.
2371
+ *
2372
+ * The discarded side is the release's own checkout, so it is always readable
2373
+ * by the gateway user even when the live side is not. Without this the log
2374
+ * says only that "the release's copy was discarded" — which reads as a
2375
+ * `.gitkeep` and hides the case that matters: a release shipping a new
2376
+ * `init.sql` or entrypoint script *inside* a path whose live copy wins, where
2377
+ * the file is dropped on every update and nothing ever says which.
2378
+ */
2379
+ describeDiscarded(destination, limit = 10) {
2380
+ let names;
2381
+ try {
2382
+ names = fs.statSync(destination).isDirectory()
2383
+ ? fs.readdirSync(destination).sort()
2384
+ : [path.basename(destination)];
2385
+ }
2386
+ catch {
2387
+ return 'contents unreadable';
2388
+ }
2389
+ if (names.length === 0)
2390
+ return 'it was empty';
2391
+ const shown = names.slice(0, limit).map((n) => `"${n}"`).join(', ');
2392
+ return names.length > limit
2393
+ ? `${shown} and ${names.length - limit} more`
2394
+ : shown;
2395
+ }
2396
+ /**
2397
+ * Undo {@link moveBindMounts}. Returns the paths it could **not** move back:
2398
+ * those still live only under `fromDir`, so the caller must keep that
2399
+ * directory rather than clean it up.
2400
+ *
2401
+ * A path that is missing at the source, or already present at the
2402
+ * destination, is not a failure — it needs no move and is not reported.
2403
+ */
2404
+ restoreBindMounts(fromDir, toDir, moved, job) {
2405
+ const unrestored = [];
2406
+ for (const rel of [...moved].reverse()) {
2407
+ if (!this.isSafeBindRel(fromDir, toDir, rel)) {
2408
+ this.log(job, `Warning: skipping bind-mount path outside the app directory: "${rel}"`);
2409
+ unrestored.push(rel);
2410
+ continue;
2411
+ }
2412
+ const source = path.resolve(fromDir, rel);
2413
+ const destination = path.resolve(toDir, rel);
2414
+ if (!fs.existsSync(source) || fs.existsSync(destination))
2415
+ continue;
2416
+ try {
2417
+ this.ensureDirWithinNoSymlink(toDir, path.dirname(destination));
2418
+ this.moveBindPath(source, destination, rel, job);
2419
+ }
2420
+ catch (err) {
2421
+ this.log(job, `Warning: could not restore bind-mount path "${rel}": ${err.message}`);
2422
+ unrestored.push(rel);
2423
+ }
2424
+ }
2425
+ return unrestored;
2426
+ }
2427
+ /**
2428
+ * Move one bind-mount path between app directories.
2429
+ *
2430
+ * `rename(2)` that moves a **directory** to a different parent needs write
2431
+ * permission on the directory itself — the kernel has to update its `..`
2432
+ * entry — not just on the two parents, which is exactly what the swap does. A bind mount the app's own container created is owned by that
2433
+ * image's uid (postgres initdb leaves its data directory mode 0700), so the
2434
+ * gateway user cannot rename it and the update swap failed with EACCES on
2435
+ * every attempt.
2436
+ *
2437
+ * The same host-vs-container ownership split is already handled elsewhere in
2438
+ * this file — backups tar through a root helper container, {@link rmrf} falls
2439
+ * back to `sudo rm -rf`. The swap was the one path still doing it bare, so
2440
+ * the fallback here matches the backup mechanism: a throwaway root container.
2441
+ *
2442
+ * The helper mounts the **nearest common ancestor** of the two paths rather
2443
+ * than each parent separately, and that is load-bearing: two bind mounts are
2444
+ * two mount points, so `rename(2)` across them fails EXDEV and busybox `mv`
2445
+ * silently degrades to copy-then-unlink. Measured by inode, two mounts give a
2446
+ * new inode (a copy) where one mount preserves it (a real rename). For a
2447
+ * database directory the degraded path means duplicating it on disk and
2448
+ * losing the atomicity the swap depends on.
2449
+ *
2450
+ * The single mount is therefore the apps root, not one app's directory — the
2451
+ * two app directories are siblings under it. The helper is a throwaway
2452
+ * container whose only command is the `mv`, and it is reached only after the
2453
+ * gateway user's own rename was refused.
2454
+ */
2455
+ moveBindPath(source, destination, rel, job) {
2456
+ try {
2457
+ fs.renameSync(source, destination);
2458
+ return;
2459
+ }
2460
+ catch (err) {
2461
+ if (!isPermissionError(err))
2462
+ throw err;
2463
+ }
2464
+ const base = commonAncestorDir(source, destination);
2465
+ const from = path.relative(base, source);
2466
+ const to = path.relative(base, destination);
2467
+ if (base === path.parse(base).root || base.includes(':') || from === '' || to === ''
2468
+ || from.split(path.sep).includes('..') || to.split(path.sep).includes('..')) {
2469
+ // A colon would be read as the mount separator by `docker -v`, silently
2470
+ // mounting something other than what was asked for.
2471
+ throw new Error(`Cannot move bind-mount path "${rel}" as root: unsafe mount base "${base}"`);
2472
+ }
2473
+ this.log(job, `Bind-mount path "${rel}" is not writable by the gateway user (created by the app's container) `
2474
+ + '— moving it with a root helper container');
2475
+ this.run([
2476
+ 'docker', 'run', '--rm',
2477
+ '-v', `${base}:/mnt`,
2478
+ BACKUP_HELPER_IMAGE,
2479
+ 'mv', path.posix.join('/mnt', from.split(path.sep).join('/')),
2480
+ path.posix.join('/mnt', to.split(path.sep).join('/')),
2481
+ ], undefined, BIND_MOVE_TIMEOUT_MS);
2482
+ }
1978
2483
  /**
1979
2484
  * Stop conflicting containers then run `docker compose up -d --wait`.
1980
2485
  * Captures container logs into the job on failure before rethrowing.
@@ -2076,6 +2581,104 @@ class AppInstaller {
2076
2581
  * container back down (issue #283). Returns a de-duplicated list of image
2077
2582
  * IDs, or `[]` on any error (nothing to reclaim / docker unavailable).
2078
2583
  */
2584
+ /**
2585
+ * Tag every image the app is *currently* running under a private
2586
+ * `<repo>:cg-rollback-<id>` name, and return the pairs.
2587
+ *
2588
+ * A `build:` service's new image reuses the tag of the one in production
2589
+ * (`<project>-<service>:latest`), so once the update's build succeeds that
2590
+ * tag names the new release. Rolling only the source directory back would
2591
+ * bring the app up on the failed release's image. Holding a second tag keeps
2592
+ * the old image addressable *and* alive — under the containerd image store an
2593
+ * untagged image is not kept around as a `<none>` image to find later.
2594
+ *
2595
+ * Only images this app builds are preserved: compose names them
2596
+ * `<project>-<service>`, and a pulled tag (`postgres:16-alpine`) is never
2597
+ * overwritten by a build, so it needs no protection. Best-effort throughout —
2598
+ * anything that cannot be preserved is simply absent from the result, and the
2599
+ * rollback rebuilds from the restored source instead.
2600
+ */
2601
+ preserveRunningImages(appName, dir, job) {
2602
+ let rows;
2603
+ try {
2604
+ const { stdout } = this.run(['docker', 'compose', '-p', appName, 'images', '--format', 'json'], dir, 15000);
2605
+ rows = parseJsonRows(stdout);
2606
+ }
2607
+ catch {
2608
+ return [];
2609
+ }
2610
+ const preserved = [];
2611
+ const seen = new Set();
2612
+ for (const row of rows) {
2613
+ if (typeof row !== 'object' || row === null)
2614
+ continue;
2615
+ const r = row;
2616
+ const id = typeof r.ID === 'string' ? r.ID.trim() : '';
2617
+ const repo = typeof r.Repository === 'string' ? r.Repository.trim() : '';
2618
+ const tag = typeof r.Tag === 'string' && r.Tag.trim() ? r.Tag.trim() : 'latest';
2619
+ if (!id || !repo.startsWith(`${appName}-`))
2620
+ continue;
2621
+ const ref = `${repo}:${tag}`;
2622
+ if (seen.has(ref))
2623
+ continue;
2624
+ seen.add(ref);
2625
+ const backupRef = `${repo}:${ROLLBACK_TAG_PREFIX}${id.replace(/^sha256:/, '').slice(0, 12)}`;
2626
+ try {
2627
+ this.run(['docker', 'image', 'tag', id, backupRef], dir, 15000);
2628
+ preserved.push({ ref, backupRef });
2629
+ }
2630
+ catch (err) {
2631
+ // Recorded with no backup reference: the rollback must know this tag is
2632
+ // unprotected and rebuild, rather than read an empty list as "no built
2633
+ // images, nothing at risk".
2634
+ preserved.push({ ref, backupRef: '' });
2635
+ this.log(job, `Warning: could not preserve image "${ref}" for rollback (${err instanceof Error ? err.message : String(err)})`);
2636
+ }
2637
+ }
2638
+ return preserved;
2639
+ }
2640
+ /**
2641
+ * Put each preserved image back on the reference the update's build took
2642
+ * over. Returns true when nothing is at risk (an empty list — the app builds
2643
+ * no images) or every reference is back on its original image; false when at
2644
+ * least one could not be, so the caller rebuilds from the rolled-back source
2645
+ * rather than starting the failed release's build.
2646
+ */
2647
+ restorePreservedImages(images, dir, job) {
2648
+ let allRestored = true;
2649
+ for (const { ref, backupRef } of images) {
2650
+ if (!backupRef) {
2651
+ allRestored = false;
2652
+ this.log(job, `Image "${ref}" was not preserved — rebuilding from the rolled-back source`);
2653
+ continue;
2654
+ }
2655
+ try {
2656
+ this.run(['docker', 'image', 'tag', backupRef, ref], dir, 15000);
2657
+ this.log(job, `Restored image "${ref}" to the pre-update build`);
2658
+ }
2659
+ catch (err) {
2660
+ allRestored = false;
2661
+ this.log(job, `Warning: could not restore image "${ref}" (${err instanceof Error ? err.message : String(err)}) — rebuilding from the rolled-back source instead`);
2662
+ }
2663
+ }
2664
+ return allRestored;
2665
+ }
2666
+ /**
2667
+ * Drop the private rollback tags. Removing a reference only untags the image
2668
+ * while another tag remains, so this never deletes an image the app still
2669
+ * uses. Must run before the post-update image reclaim: an extra tag would
2670
+ * make `docker image rm <id>` refuse.
2671
+ */
2672
+ dropPreservedImageTags(images) {
2673
+ for (const { backupRef } of images) {
2674
+ if (!backupRef)
2675
+ continue;
2676
+ try {
2677
+ this.run(['docker', 'image', 'rm', backupRef], os.tmpdir(), 15000);
2678
+ }
2679
+ catch { /* already gone — non-fatal */ }
2680
+ }
2681
+ }
2079
2682
  captureComposeImageIds(appName, dir) {
2080
2683
  try {
2081
2684
  const { stdout } = this.run(['docker', 'compose', '-p', appName, 'images', '--quiet'], dir, 15000);