@rubytech/create-maxy-code 0.1.459 → 0.1.461

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 (189) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
  2. package/dist/__tests__/launchd-plist.test.js +26 -0
  3. package/dist/cron-registration.js +30 -8
  4. package/dist/index.js +50 -9
  5. package/dist/launchd-plist.js +1 -1
  6. package/dist/uninstall.js +9 -3
  7. package/package.json +1 -1
  8. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js +116 -6
  9. package/payload/platform/lib/storage-broker/dist/__tests__/cf-exec.test.js.map +1 -1
  10. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js +20 -0
  11. package/payload/platform/lib/storage-broker/dist/__tests__/object-limits.test.js.map +1 -1
  12. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts +2 -0
  13. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.d.ts.map +1 -0
  14. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js +41 -0
  15. package/payload/platform/lib/storage-broker/dist/__tests__/resource-limits.test.js.map +1 -0
  16. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts +1 -0
  17. package/payload/platform/lib/storage-broker/dist/cf-exec.d.ts.map +1 -1
  18. package/payload/platform/lib/storage-broker/dist/cf-exec.js +116 -48
  19. package/payload/platform/lib/storage-broker/dist/cf-exec.js.map +1 -1
  20. package/payload/platform/lib/storage-broker/dist/index.d.ts +1 -0
  21. package/payload/platform/lib/storage-broker/dist/index.d.ts.map +1 -1
  22. package/payload/platform/lib/storage-broker/dist/index.js +1 -0
  23. package/payload/platform/lib/storage-broker/dist/index.js.map +1 -1
  24. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts +27 -0
  25. package/payload/platform/lib/storage-broker/dist/object-limits.d.ts.map +1 -1
  26. package/payload/platform/lib/storage-broker/dist/object-limits.js +27 -7
  27. package/payload/platform/lib/storage-broker/dist/object-limits.js.map +1 -1
  28. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts +102 -0
  29. package/payload/platform/lib/storage-broker/dist/resource-limits.d.ts.map +1 -0
  30. package/payload/platform/lib/storage-broker/dist/resource-limits.js +105 -0
  31. package/payload/platform/lib/storage-broker/dist/resource-limits.js.map +1 -0
  32. package/payload/platform/lib/storage-broker/src/__tests__/cf-exec.test.ts +130 -7
  33. package/payload/platform/lib/storage-broker/src/__tests__/object-limits.test.ts +30 -0
  34. package/payload/platform/lib/storage-broker/src/__tests__/resource-limits.test.ts +49 -0
  35. package/payload/platform/lib/storage-broker/src/cf-exec.ts +129 -56
  36. package/payload/platform/lib/storage-broker/src/index.ts +1 -0
  37. package/payload/platform/lib/storage-broker/src/object-limits.ts +26 -6
  38. package/payload/platform/lib/storage-broker/src/resource-limits.ts +105 -0
  39. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +5 -4
  40. package/payload/platform/plugins/cloudflare/PLUGIN.md +3 -1
  41. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
  42. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +220 -0
  43. package/payload/platform/plugins/cloudflare/mcp/__tests__/authorize.test.ts +201 -0
  44. package/payload/platform/plugins/cloudflare/mcp/__tests__/file-routes.test.ts +178 -0
  45. package/payload/platform/plugins/cloudflare/mcp/__tests__/harness.test.ts +32 -0
  46. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +204 -0
  47. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
  48. package/payload/platform/plugins/cloudflare/mcp/__tests__/ratelimit.test.ts +77 -0
  49. package/payload/platform/plugins/cloudflare/mcp/__tests__/skill-contract.test.ts +46 -0
  50. package/payload/platform/plugins/cloudflare/mcp/__tests__/template-config.test.ts +55 -0
  51. package/payload/platform/plugins/cloudflare/mcp/__tests__/upload-route.test.ts +255 -0
  52. package/payload/platform/plugins/cloudflare/mcp/package.json +5 -2
  53. package/payload/platform/plugins/cloudflare/references/r2-object-storage.md +46 -0
  54. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +125 -0
  55. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/authorize.ts +32 -0
  56. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/log.ts +12 -0
  57. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
  58. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/ratelimit.ts +55 -0
  59. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/session.ts +85 -0
  60. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/types.ts +44 -0
  61. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +119 -0
  62. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/delete.ts +89 -0
  63. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/download.ts +70 -0
  64. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/files.ts +41 -0
  65. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/upload.ts +151 -0
  66. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/index.html +42 -0
  67. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.css +113 -0
  68. package/payload/platform/plugins/cloudflare/skills/data-portal/template/public/portal.js +155 -0
  69. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +73 -0
  70. package/payload/platform/plugins/cloudflare/skills/data-portal/template/wrangler.toml +21 -0
  71. package/payload/platform/plugins/cloudflare/skills/site-deploy/SKILL.md +8 -0
  72. package/payload/platform/plugins/docs/references/outlook-guide.md +3 -3
  73. package/payload/platform/plugins/docs/references/voice-mirror-guide.md +1 -0
  74. package/payload/platform/plugins/filesystem/PLUGIN.md +0 -1
  75. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts +14 -10
  76. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.d.ts.map +1 -1
  77. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js +14 -10
  78. package/payload/platform/plugins/filesystem/mcp/dist/lib/scope.js.map +1 -1
  79. package/payload/platform/plugins/outlook/.claude-plugin/plugin.json +1 -1
  80. package/payload/platform/plugins/outlook/PLUGIN.md +3 -5
  81. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts +2 -0
  82. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.d.ts.map +1 -0
  83. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js +75 -0
  84. package/payload/platform/plugins/outlook/mcp/dist/__tests__/detach.test.js.map +1 -0
  85. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts +2 -0
  86. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.d.ts.map +1 -0
  87. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js +187 -0
  88. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit-removal.test.js.map +1 -0
  89. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js +1 -1
  90. package/payload/platform/plugins/outlook/mcp/dist/__tests__/draft-edit.test.js.map +1 -1
  91. package/payload/platform/plugins/outlook/mcp/dist/index.js +3 -2
  92. package/payload/platform/plugins/outlook/mcp/dist/index.js.map +1 -1
  93. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts +43 -0
  94. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.d.ts.map +1 -0
  95. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js +65 -0
  96. package/payload/platform/plugins/outlook/mcp/dist/lib/attachment-list.js.map +1 -0
  97. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts +65 -0
  98. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.d.ts.map +1 -0
  99. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js +99 -0
  100. package/payload/platform/plugins/outlook/mcp/dist/lib/detach.js.map +1 -0
  101. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts +6 -1
  102. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.d.ts.map +1 -1
  103. package/payload/platform/plugins/outlook/mcp/dist/lib/outbound-attachments.js.map +1 -1
  104. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts +42 -22
  105. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.d.ts.map +1 -1
  106. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js +59 -23
  107. package/payload/platform/plugins/outlook/mcp/dist/tools/draft-edit.js.map +1 -1
  108. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts +15 -16
  109. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.d.ts.map +1 -1
  110. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js +8 -41
  111. package/payload/platform/plugins/outlook/mcp/dist/tools/mail-attachment.js.map +1 -1
  112. package/payload/platform/plugins/outlook/references/auth.md +1 -1
  113. package/payload/platform/plugins/outlook/references/graph-surfaces.md +7 -3
  114. package/payload/platform/plugins/outlook/skills/outlook/SKILL.md +1 -1
  115. package/payload/platform/plugins/storage-broker/PLUGIN.md +1 -1
  116. package/payload/platform/plugins/voice-mirror/PLUGIN.md +19 -7
  117. package/payload/platform/plugins/voice-mirror/mcp/dist/index.d.ts.map +1 -1
  118. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js +35 -16
  119. package/payload/platform/plugins/voice-mirror/mcp/dist/index.js.map +1 -1
  120. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts +31 -0
  121. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.d.ts.map +1 -0
  122. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js +87 -0
  123. package/payload/platform/plugins/voice-mirror/mcp/dist/lib/admin-user.js.map +1 -0
  124. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  125. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +111 -12
  126. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  127. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.d.ts.map +1 -1
  128. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js +2 -1
  129. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-ingest-session-text.js.map +1 -1
  130. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.d.ts.map +1 -1
  131. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js +2 -1
  132. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-record-feedback.js.map +1 -1
  133. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  134. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
  135. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  136. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts +2 -2
  137. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.d.ts.map +1 -1
  138. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js +12 -3
  139. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-tag-content.js.map +1 -1
  140. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +444 -0
  141. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +18 -2
  142. package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
  143. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
  144. package/payload/platform/scripts/logs-rotate.sh +204 -0
  145. package/payload/platform/scripts/resume-tunnel.sh +27 -0
  146. package/payload/platform/scripts/voice-author-key-audit.sh +155 -0
  147. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  148. package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
  149. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  150. package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
  151. package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
  152. package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
  153. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  154. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
  155. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
  156. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
  157. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
  158. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts +17 -0
  159. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.d.ts.map +1 -0
  160. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js +88 -0
  161. package/payload/platform/services/claude-session-manager/dist/mcp-descriptor-resolve.js.map +1 -0
  162. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
  163. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  164. package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
  165. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  166. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +0 -1
  167. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  168. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +36 -24
  169. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  170. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  171. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
  172. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  173. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
  174. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
  175. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
  176. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
  177. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
  178. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
  179. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
  180. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
  181. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
  182. package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
  183. package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
  184. package/payload/server/{chunk-Q6W4U6HL.js → chunk-JXWFVE5X.js} +83 -39
  185. package/payload/server/server.js +268 -73
  186. package/payload/server/{src-3I2RYZFB.js → src-4F37OHLK.js} +11 -1
  187. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.d.ts +0 -2
  188. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js +0 -215
  189. package/payload/platform/plugins/outlook/mcp/dist/__tests__/pkce-flow.test.js.map +0 -1
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for logs-rotate.sh (Task 1706). Fixture-driven: builds a throwaway logs
3
+ # dir per case, runs the real script against it, asserts on the resulting files.
4
+ # No stubs — gzip/find/wc/date are the real binaries, because the portability of
5
+ # exactly those calls across Linux and darwin is part of what this asserts.
6
+ set -uo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ ROTATE="$SCRIPT_DIR/../logs-rotate.sh"
10
+
11
+ PASS=0
12
+ FAIL=0
13
+
14
+ run_case() {
15
+ local name="$1"; shift
16
+ echo ""
17
+ echo "=== CASE: $name ==="
18
+ if "$@"; then
19
+ PASS=$((PASS + 1))
20
+ echo "PASS: $name"
21
+ else
22
+ FAIL=$((FAIL + 1))
23
+ echo "FAIL: $name"
24
+ fi
25
+ }
26
+
27
+ # Build a throwaway logs dir. Echoes its path.
28
+ mk_logs_dir() {
29
+ local d
30
+ d="$(mktemp -d "${TMPDIR:-/tmp}/logs-rotate-test.XXXXXX")"
31
+ echo "$d"
32
+ }
33
+
34
+ # Write a file of exactly $2 bytes at $1. $2 must be a whole number of MiB.
35
+ # bs=1048576 rather than bs=1m/1M: the suffix spellings differ between BSD and
36
+ # GNU dd, and this suite runs on both.
37
+ mk_file_of_size() {
38
+ local path="$1" bytes="$2" mib
39
+ mib=$(( bytes / 1048576 ))
40
+ dd if=/dev/zero bs=1048576 count="$mib" 2>/dev/null | tr '\0' 'x' > "$path"
41
+ }
42
+
43
+ size_of() { wc -c < "$1" 2>/dev/null | awk '{print $1+0}'; }
44
+
45
+ # Backdate a path by $2 days. `date -r` is BSD, `date -d @` is GNU.
46
+ backdate() {
47
+ local path="$1" days="$2" secs
48
+ secs=$(( $(date +%s) - days * 86400 ))
49
+ touch -t "$(date -u -r "$secs" +%Y%m%d%H%M 2>/dev/null || date -u -d "@$secs" +%Y%m%d%H%M)" "$path"
50
+ }
51
+
52
+ case_under_threshold_is_noop() {
53
+ local d; d="$(mk_logs_dir)"
54
+ # 63 MiB — just under the 64 MiB threshold, so this also pins the boundary.
55
+ mk_file_of_size "$d/server.log" $((63 * 1024 * 1024))
56
+ "$ROTATE" "$d" >/dev/null 2>&1
57
+ local rc=$?
58
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$d"; return 1; }
59
+ [ ! -e "$d/server.log.1.gz" ] || { echo " rotated a 63 MiB file"; rm -rf "$d"; return 1; }
60
+ [ "$(size_of "$d/server.log")" -eq $((63 * 1024 * 1024)) ] \
61
+ || { echo " file was modified"; rm -rf "$d"; return 1; }
62
+ rm -rf "$d"; return 0
63
+ }
64
+
65
+ case_over_threshold_rotates_and_truncates() {
66
+ local d; d="$(mk_logs_dir)"
67
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
68
+ "$ROTATE" "$d" >/dev/null 2>&1
69
+ [ -f "$d/server.log.1.gz" ] || { echo " no .1.gz produced"; rm -rf "$d"; return 1; }
70
+ # The live file must still exist (readers hardcode the path) and be small:
71
+ # it holds only the marker line.
72
+ [ -f "$d/server.log" ] || { echo " live path missing after rotate"; rm -rf "$d"; return 1; }
73
+ local live; live="$(size_of "$d/server.log")"
74
+ [ "$live" -lt 4096 ] || { echo " live file not truncated: $live bytes"; rm -rf "$d"; return 1; }
75
+ rm -rf "$d"; return 0
76
+ }
77
+
78
+ case_marker_line_present() {
79
+ local d; d="$(mk_logs_dir)"
80
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
81
+ "$ROTATE" "$d" >/dev/null 2>&1
82
+ grep -q '\[logs-rotate\] rotated file=server.log prev-bytes=68157440 prev-file=server.log.1.gz keep=5' "$d/server.log" \
83
+ || { echo " marker line absent or malformed:"; cat "$d/server.log"; rm -rf "$d"; return 1; }
84
+ # The marker must be datable from itself.
85
+ grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z \[logs-rotate\]' "$d/server.log" \
86
+ || { echo " marker line not timestamped"; rm -rf "$d"; return 1; }
87
+ rm -rf "$d"; return 0
88
+ }
89
+
90
+ case_generation_shift_drops_the_sixth() {
91
+ local d; d="$(mk_logs_dir)"
92
+ # Seed 5 existing generations, each identifiable by content.
93
+ local i
94
+ for i in 1 2 3 4 5; do echo "gen$i" | gzip -c > "$d/server.log.$i.gz"; done
95
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
96
+ "$ROTATE" "$d" >/dev/null 2>&1
97
+ # .5.gz must now hold what .4.gz held; the old .5.gz is gone.
98
+ [ "$(gzip -cd "$d/server.log.5.gz")" = "gen4" ] || { echo " .5.gz is not the old .4.gz"; rm -rf "$d"; return 1; }
99
+ [ "$(gzip -cd "$d/server.log.2.gz")" = "gen1" ] || { echo " .2.gz is not the old .1.gz"; rm -rf "$d"; return 1; }
100
+ [ ! -e "$d/server.log.6.gz" ] || { echo " a 6th generation was created"; rm -rf "$d"; return 1; }
101
+ rm -rf "$d"; return 0
102
+ }
103
+
104
+ case_lock_held_is_noop_exit_zero() {
105
+ local d; d="$(mk_logs_dir)"
106
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
107
+ mkdir "$d/.rotate.lock" # fresh lock, not stale
108
+ "$ROTATE" "$d" >/dev/null 2>&1
109
+ local rc=$?
110
+ [ "$rc" -eq 0 ] || { echo " expected exit 0 when lock held, got $rc"; rm -rf "$d"; return 1; }
111
+ [ ! -e "$d/server.log.1.gz" ] || { echo " rotated despite held lock"; rm -rf "$d"; return 1; }
112
+ # A held lock must survive the tick that could not take it.
113
+ [ -d "$d/.rotate.lock" ] || { echo " another holder's lock was removed"; rm -rf "$d"; return 1; }
114
+ rm -rf "$d"; return 0
115
+ }
116
+
117
+ case_stale_lock_is_reclaimed() {
118
+ local d; d="$(mk_logs_dir)"
119
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
120
+ mkdir "$d/.rotate.lock"
121
+ backdate "$d/.rotate.lock" 1 # 1 day > STALE_LOCK_MINUTES=30
122
+ "$ROTATE" "$d" >/dev/null 2>&1
123
+ [ -f "$d/server.log.1.gz" ] || { echo " stale lock was not reclaimed"; rm -rf "$d"; return 1; }
124
+ rm -rf "$d"; return 0
125
+ }
126
+
127
+ case_missing_logs_dir_is_noop_exit_zero() {
128
+ "$ROTATE" "/nonexistent/logs/dir/for/test" >/dev/null 2>&1
129
+ local rc=$?
130
+ [ "$rc" -eq 0 ] || { echo " expected exit 0 on missing dir, got $rc"; return 1; }
131
+ return 0
132
+ }
133
+
134
+ case_lock_released_on_exit() {
135
+ local d; d="$(mk_logs_dir)"
136
+ mk_file_of_size "$d/server.log" $((1 * 1024 * 1024))
137
+ "$ROTATE" "$d" >/dev/null 2>&1
138
+ [ ! -e "$d/.rotate.lock" ] || { echo " lock not released"; rm -rf "$d"; return 1; }
139
+ rm -rf "$d"; return 0
140
+ }
141
+
142
+ case_purges_rc_child_older_than_seven_days() {
143
+ local d; d="$(mk_logs_dir)"
144
+ echo old > "$d/rc-child-aaaa.log"; backdate "$d/rc-child-aaaa.log" 9
145
+ echo new > "$d/rc-child-bbbb.log" # today
146
+ "$ROTATE" "$d" >/dev/null 2>&1
147
+ [ ! -e "$d/rc-child-aaaa.log" ] || { echo " 9-day-old rc-child survived"; rm -rf "$d"; return 1; }
148
+ [ -f "$d/rc-child-bbbb.log" ] || { echo " today's rc-child was purged"; rm -rf "$d"; return 1; }
149
+ rm -rf "$d"; return 0
150
+ }
151
+
152
+ case_purge_does_not_touch_server_log() {
153
+ local d; d="$(mk_logs_dir)"
154
+ echo old > "$d/server.log"; backdate "$d/server.log" 90
155
+ echo x | gzip -c > "$d/server.log.1.gz"; backdate "$d/server.log.1.gz" 90
156
+ "$ROTATE" "$d" >/dev/null 2>&1
157
+ [ -f "$d/server.log" ] || { echo " server.log was purged by age"; rm -rf "$d"; return 1; }
158
+ [ -f "$d/server.log.1.gz" ] || { echo " a kept generation was purged by age"; rm -rf "$d"; return 1; }
159
+ rm -rf "$d"; return 0
160
+ }
161
+
162
+ case_mcp_stderr_live_file_size_rotates() {
163
+ local d; d="$(mk_logs_dir)"
164
+ mk_file_of_size "$d/mcp-memory-stderr-2026-07-17.log" $((65 * 1024 * 1024))
165
+ "$ROTATE" "$d" >/dev/null 2>&1
166
+ [ -f "$d/mcp-memory-stderr-2026-07-17.log.1.gz" ] \
167
+ || { echo " live mcp stderr log was not size-rotated"; rm -rf "$d"; return 1; }
168
+ rm -rf "$d"; return 0
169
+ }
170
+
171
+ # --- Code-review regressions (2026-07-17) -----------------------------------
172
+
173
+ # A failing gzip must not cost a generation. The shift used to run BEFORE the
174
+ # gzip, so every failure moved .4->.5 ... .1->.2 and wrote nothing to .1 —
175
+ # leaving a hole and dropping the oldest. The trigger is ENOSPC, i.e. exactly
176
+ # the condition rotation exists to prevent: 5 failing ticks destroyed 4 of the
177
+ # 5 retained generations while never truncating the live file.
178
+ case_gzip_failure_preserves_generations() {
179
+ local d; d="$(mk_logs_dir)"
180
+ local bin="$d/../bin-$$"; mkdir -p "$bin"
181
+ printf '#!/bin/sh\nexit 1\n' > "$bin/gzip"; chmod +x "$bin/gzip"
182
+ local i
183
+ for i in 1 2 3 4 5; do echo "gen$i" | gzip -c > "$d/server.log.$i.gz"; done
184
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
185
+
186
+ PATH="$bin:$PATH" "$ROTATE" "$d" >/dev/null 2>&1
187
+ local rc=$?
188
+
189
+ local fail=0
190
+ for i in 1 2 3 4 5; do
191
+ if [ "$(gzip -cd "$d/server.log.$i.gz" 2>/dev/null)" != "gen$i" ]; then
192
+ echo " .$i.gz should still be gen$i, got: $(gzip -cd "$d/server.log.$i.gz" 2>/dev/null)"; fail=1
193
+ fi
194
+ done
195
+ # The live file must be untouched — a failed rotate never truncates.
196
+ [ "$(size_of "$d/server.log")" -eq $((65 * 1024 * 1024)) ] \
197
+ || { echo " live file was truncated despite gzip failure"; fail=1; }
198
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; fail=1; }
199
+ [ ! -e "$d/server.log.1.gz.tmp" ] || { echo " temp file left behind"; fail=1; }
200
+ rm -rf "$d" "$bin"; return $fail
201
+ }
202
+
203
+ # The mcp stderr tee opens its file at process start and holds the stream for the
204
+ # process's whole life, writing ONLY when the server emits stderr — which a
205
+ # healthy server does not do for days. So a live file's mtime DOES go stale, and
206
+ # deleting it sends every later stderr write to an unlinked inode: invisible
207
+ # exactly when the fault you are chasing finally appears. Never purge these.
208
+ case_never_purges_mcp_stderr_by_age() {
209
+ local d; d="$(mk_logs_dir)"
210
+ echo quiet > "$d/mcp-memory-stderr-2026-01-01.log"
211
+ backdate "$d/mcp-memory-stderr-2026-01-01.log" 90
212
+ echo x | gzip -c > "$d/mcp-memory-stderr-2026-01-01.log.1.gz"
213
+ backdate "$d/mcp-memory-stderr-2026-01-01.log.1.gz" 90
214
+ "$ROTATE" "$d" >/dev/null 2>&1
215
+ [ -f "$d/mcp-memory-stderr-2026-01-01.log" ] \
216
+ || { echo " a 90-day-stale mcp stderr log was purged — it may still be held open"; rm -rf "$d"; return 1; }
217
+ [ -f "$d/mcp-memory-stderr-2026-01-01.log.1.gz" ] \
218
+ || { echo " an mcp generation was purged by age"; rm -rf "$d"; return 1; }
219
+ rm -rf "$d"; return 0
220
+ }
221
+
222
+ # rc-child's 16 MiB cap does NOT hold: `written` is in-memory per manager
223
+ # process, so a manager restart resumes appending to the same path. Observed at
224
+ # 54 MB on sitedesk-code (3.25x the cap). A live long-lived session's file has a
225
+ # fresh mtime forever, so age-purge never reaches it — it needs size rotation.
226
+ case_rc_child_size_rotates() {
227
+ local d; d="$(mk_logs_dir)"
228
+ mk_file_of_size "$d/rc-child-live-session.log" $((65 * 1024 * 1024))
229
+ "$ROTATE" "$d" >/dev/null 2>&1
230
+ [ -f "$d/rc-child-live-session.log.1.gz" ] \
231
+ || { echo " an oversized rc-child log was not size-rotated"; rm -rf "$d"; return 1; }
232
+ local live; live="$(size_of "$d/rc-child-live-session.log")"
233
+ [ "$live" -lt 4096 ] || { echo " rc-child live file not truncated: $live"; rm -rf "$d"; return 1; }
234
+ rm -rf "$d"; return 0
235
+ }
236
+
237
+ run_case "gzip failure preserves generations" case_gzip_failure_preserves_generations
238
+ run_case "never purges mcp stderr by age" case_never_purges_mcp_stderr_by_age
239
+ run_case "rc-child size-rotates" case_rc_child_size_rotates
240
+
241
+ run_case "under threshold is a no-op" case_under_threshold_is_noop
242
+ run_case "over threshold rotates and truncates" case_over_threshold_rotates_and_truncates
243
+ run_case "marker line present and timestamped" case_marker_line_present
244
+ run_case "generation shift drops the sixth" case_generation_shift_drops_the_sixth
245
+ run_case "lock held is a no-op, exit 0" case_lock_held_is_noop_exit_zero
246
+ run_case "stale lock is reclaimed" case_stale_lock_is_reclaimed
247
+ run_case "missing logs dir is a no-op, exit 0" case_missing_logs_dir_is_noop_exit_zero
248
+ run_case "lock released on exit" case_lock_released_on_exit
249
+ run_case "purges rc-child older than 7 days" case_purges_rc_child_older_than_seven_days
250
+ run_case "purge does not touch server.log" case_purge_does_not_touch_server_log
251
+ run_case "mcp stderr live file size-rotates" case_mcp_stderr_live_file_size_rotates
252
+
253
+ echo ""
254
+ echo "===================="
255
+ echo "PASS: $PASS FAIL: $FAIL"
256
+ [ "$FAIL" -eq 0 ]
@@ -619,6 +619,83 @@ case_refuse_on_invalid_state_json() {
619
619
  return 0
620
620
  }
621
621
 
622
+ # Task 1706 — the rotator's lock must be held across the offset-capture →
623
+ # verification-poll window. logs-rotate.sh truncates cloudflared.log in place
624
+ # when it exceeds 64 MiB; a rotation landing inside this window makes
625
+ # `tail -c "+$LOG_OFFSET"` read past EOF and report a tunnel failure that did
626
+ # not happen.
627
+ case_holds_rotate_lock_during_verify() {
628
+ local root
629
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
630
+ setup "$root" ".maxy-code" "inactive" "yes"
631
+
632
+ local log_dir="$root/home/.maxy-code/logs"
633
+ local marker="$root/lock-was-held"
634
+
635
+ # Poll for the lock's existence while the script runs. The verify gate is the
636
+ # window under test, so a single observation of the lock proves it was held
637
+ # across it.
638
+ ( for _ in $(seq 1 60); do
639
+ [[ -d "$log_dir/.rotate.lock" ]] && { touch "$marker"; break; }
640
+ sleep 0.05
641
+ done ) &
642
+ local watcher=$!
643
+
644
+ HOME="$root/home" \
645
+ PATH="$root/bin:$PATH" \
646
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
647
+ bash "$RESUME" >/dev/null 2>&1
648
+ local rc=$?
649
+ wait "$watcher" 2>/dev/null
650
+
651
+ local held=0 leaked=0
652
+ [[ -e "$marker" ]] && held=1
653
+ [[ -d "$log_dir/.rotate.lock" ]] && leaked=1
654
+ cleanup "$root"
655
+
656
+ if [[ $rc -ne 0 ]]; then
657
+ echo " expected exit 0, got $rc"; return 1
658
+ fi
659
+ if [[ $held -ne 1 ]]; then
660
+ echo " rotate lock was never held during the verify window"; return 1
661
+ fi
662
+ if [[ $leaked -ne 0 ]]; then
663
+ echo " rotate lock not released after the verify window"; return 1
664
+ fi
665
+ return 0
666
+ }
667
+
668
+ # A lock already held (by a live rotator tick) must not stop the tunnel coming
669
+ # up. A false-alarm verify line is a far smaller failure than no tunnel.
670
+ case_busy_rotate_lock_does_not_block_spawn() {
671
+ local root
672
+ root=$(mktemp -d /tmp/resume-tunnel-test-XXXXXX)
673
+ setup "$root" ".maxy-code" "inactive" "yes"
674
+
675
+ mkdir -p "$root/home/.maxy-code/logs/.rotate.lock"
676
+
677
+ HOME="$root/home" \
678
+ PATH="$root/bin:$PATH" \
679
+ MAXY_PLATFORM_ROOT="$root/platform_root" \
680
+ bash "$RESUME" >/dev/null 2>&1
681
+ local rc=$?
682
+
683
+ local log_content
684
+ log_content=$(cat "$root/home/.maxy-code/logs/cloudflared.log" 2>/dev/null || echo "")
685
+ cleanup "$root"
686
+
687
+ if [[ $rc -ne 0 ]]; then
688
+ echo " expected exit 0, got $rc"; return 1
689
+ fi
690
+ if ! echo "$log_content" | grep -q "spawned service=cloudflared-maxy-code.service"; then
691
+ echo " a busy rotate lock blocked the tunnel spawn"; echo "$log_content"; return 1
692
+ fi
693
+ if ! echo "$log_content" | grep -q "rotate-lock busy"; then
694
+ echo " proceeding without the lock was not logged"; echo "$log_content"; return 1
695
+ fi
696
+ return 0
697
+ }
698
+
622
699
  run_case "skip when service active" case_skip_when_scope_active
623
700
  run_case "spawn succeeds and logs verified" case_spawn_succeeds
624
701
  run_case "spawn with no connection logs ERROR exits 0" case_spawn_no_connection
@@ -635,6 +712,8 @@ run_case "reap: skip when active unit's MainPID is unresolved" case_reap_skip_wh
635
712
  run_case "token tunnel spawns without --origincert" case_token_tunnel_spawns_without_origincert
636
713
  run_case "configured tunnel with no run credential refuses loudly" case_refuse_when_no_run_credential
637
714
  run_case "corrupt tunnel.state refuses loudly and exits 0" case_refuse_on_invalid_state_json
715
+ run_case "holds the rotate lock during verify" case_holds_rotate_lock_during_verify
716
+ run_case "busy rotate lock does not block spawn" case_busy_rotate_lock_does_not_block_spawn
638
717
 
639
718
  echo ""
640
719
  echo "Results: $PASS passed, $FAIL failed"
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env bash
2
+ # Brand log rotation — Task 1706.
3
+ #
4
+ # WHY THIS EXISTS AND NOT logrotate: server.log, edge.log and cloudflared.log
5
+ # are systemd stdout sinks (`StandardOutput=append:`, see
6
+ # packages/create-maxy-code/src/port-resolution.ts:233 and
7
+ # platform/scripts/resume-tunnel.sh:230). systemd opens the file O_APPEND ONCE
8
+ # at unit start and the process holds that fd for its whole life. logrotate's
9
+ # default rename-then-create would leave every service writing into the rotated
10
+ # inode forever — rotation would look like it worked while the live file stayed
11
+ # empty. So we gzip a COPY and truncate IN PLACE: because the fd is O_APPEND,
12
+ # each write seeks to end-of-file first, so writes resume at offset 0 with no
13
+ # service restart and no sparse hole. Verified against a real systemd unit on
14
+ # 2026-07-17 (laptop 192.168.88.10): NRestarts=0, file 71 -> 0 -> 80 bytes for
15
+ # 10 fresh lines. Never replace `: > "$F"` with a mv.
16
+ #
17
+ # Portability: this runs on Linux (cron) and darwin (launchd StartInterval), so
18
+ # no flock(1) (absent on darwin) and no `date +%N` (GNU-only). The lock is a
19
+ # mkdir, which is atomic on both. find's -mmin/-mtime/-delete and the timestamp
20
+ # format were confirmed identical on both platforms.
21
+ #
22
+ # Usage: logs-rotate.sh [LOGS_DIR]
23
+ set -uo pipefail
24
+
25
+ ROTATE_SIZE_BYTES=$((64 * 1024 * 1024))
26
+ KEEP=5
27
+ STALE_LOCK_MINUTES=30
28
+
29
+ # Resolve the logs dir the same way logs-read.sh:52-58 does: convention is
30
+ # configDir = ".${installDir}".
31
+ if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then
32
+ LOGS_DIR="$1"
33
+ else
34
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
35
+ PLATFORM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
36
+ INSTALL_DIR="$(dirname "$PLATFORM_ROOT")"
37
+ LOGS_DIR="$HOME/.$(basename "$INSTALL_DIR")/logs"
38
+ fi
39
+
40
+ # Absent logs dir is not an error: the installer creates it, and a rotator tick
41
+ # that lands before the first install completes has nothing to do.
42
+ [ -d "$LOGS_DIR" ] || exit 0
43
+
44
+ LOCK="$LOGS_DIR/.rotate.lock"
45
+
46
+ stamp() { date -u +%Y-%m-%dT%H:%M:%SZ; }
47
+
48
+ # A mkdir lock is NOT released by the kernel when its holder dies, so a crashed
49
+ # rotator would wedge rotation permanently and silently — worse than the race
50
+ # the lock prevents. Reclaim on age: 30 min is two rotator ticks and 90x
51
+ # resume-tunnel.sh's 20s window, so no live holder is mistaken for a dead one.
52
+ #
53
+ # The reclaim moves the stale lock aside with `mv` before recreating it, rather
54
+ # than `rm -rf`-then-mkdir. `mv` is the atomic gate: if two ticks both see the
55
+ # same stale lock, only one can move that inode — the loser's `mv` fails because
56
+ # the source is already gone, and it backs off. rm-then-mkdir had no such gate:
57
+ # the loser would delete the winner's brand-new lock and both would rotate,
58
+ # shifting generations twice for one new .1.gz.
59
+ acquire_lock() {
60
+ if mkdir "$LOCK" 2>/dev/null; then return 0; fi
61
+ if [ -d "$LOCK" ] && [ -z "$(find "$LOCK" -maxdepth 0 -mmin -"$STALE_LOCK_MINUTES" 2>/dev/null)" ]; then
62
+ local stale="$LOCK.stale.$$"
63
+ mv "$LOCK" "$stale" 2>/dev/null || return 1 # lost the reclaim race
64
+ rm -rf "$stale" 2>/dev/null
65
+ echo "$(stamp) [logs-rotate] stale-lock-reclaimed lock=$LOCK age-min=>$STALE_LOCK_MINUTES"
66
+ mkdir "$LOCK" 2>/dev/null && return 0 # a third tick may have won; then back off
67
+ fi
68
+ return 1
69
+ }
70
+
71
+ if ! acquire_lock; then
72
+ # Another rotator tick, or a resume-tunnel.sh verification window, is live.
73
+ # Exit 0: the next tick is 15 min away and the bound is not tight enough for
74
+ # a skipped tick to matter.
75
+ echo "$(stamp) [logs-rotate] skipped reason=lock-held lock=$LOCK"
76
+ exit 0
77
+ fi
78
+ trap 'rmdir "$LOCK" 2>/dev/null || true' EXIT
79
+
80
+ size_of() { wc -c < "$1" 2>/dev/null | awk '{print $1+0}'; }
81
+
82
+ # Size-rotate one file: gzip a copy to a temp, shift older generations, move the
83
+ # temp into .1.gz, truncate in place, then mark the truncation so the file is
84
+ # never mistaken for one that simply starts there.
85
+ #
86
+ # ORDER IS LOAD-BEARING: gzip FIRST, shift only once it succeeded. The shift used
87
+ # to run first, so a gzip failure moved .4->.5 ... .1->.2 and wrote nothing to
88
+ # .1 — dropping the oldest generation and leaving a hole, on every tick. The
89
+ # trigger is ENOSPC, i.e. precisely the condition rotation exists to prevent: 75
90
+ # minutes of a full disk destroyed 4 of the 5 retained generations while
91
+ # correctly refusing to truncate. Never hoist the shift above the gzip.
92
+ rotate_file() {
93
+ local f="$1" base size i prev
94
+ [ -f "$f" ] || return 0
95
+ size="$(size_of "$f")"
96
+ [ "$size" -gt "$ROTATE_SIZE_BYTES" ] || return 0
97
+ base="$(basename "$f")"
98
+
99
+ # gzip to a temp first, so a failure (or a tick killed mid-compress) costs
100
+ # nothing: no generation has moved and the live file is untouched.
101
+ #
102
+ # gzip's stderr is CAPTURED, not discarded, and relayed verbatim on failure —
103
+ # it is the only thing that distinguishes ENOSPC from EACCES from a missing
104
+ # binary. `2>&1 >file` sends stderr to the command substitution and stdout to
105
+ # the temp; the order matters.
106
+ #
107
+ # A live log grows while gzip reads it, so gzip emits "file size changed while
108
+ # zipping" on nearly every real rotation. That is a WARNING and gzip still
109
+ # exits 0 (verified on the laptop against a file being appended to during the
110
+ # read), so it takes the success path and the noise is never logged. Only a
111
+ # genuine non-zero exit reaches the branch below.
112
+ local gzerr
113
+ gzerr="$(gzip -c "$f" 2>&1 >"$f.1.gz.tmp")"
114
+ if [ $? -ne 0 ]; then
115
+ rm -f "$f.1.gz.tmp"
116
+ echo "$(stamp) [logs-rotate] ERROR file=$base reason=gzip-failed bytes=$size err=$(printf '%s' "$gzerr" | tr '\n' ' ') — not truncated, generations intact"
117
+ return 1
118
+ fi
119
+
120
+ # Shift down from the oldest so nothing is overwritten before it moves. The
121
+ # KEEP-th generation is overwritten by the (KEEP-1)-th, which is how the
122
+ # oldest falls off.
123
+ i="$KEEP"
124
+ while [ "$i" -gt 1 ]; do
125
+ prev=$((i - 1))
126
+ [ -f "$f.$prev.gz" ] && mv -f "$f.$prev.gz" "$f.$i.gz"
127
+ i="$prev"
128
+ done
129
+ mv -f "$f.1.gz.tmp" "$f.1.gz"
130
+
131
+ # THE load-bearing line. Truncate in place; do not mv. See the header.
132
+ #
133
+ # Lines written between gzip reaching EOF and this truncate are LOST. gzip
134
+ # reads to the CURRENT end of file, so the window is the shift plus two mvs —
135
+ # milliseconds, not the seconds the gzip itself takes. Inherent to
136
+ # copy-truncate, which the O_APPEND fd leaves no way around.
137
+ #
138
+ # `size` was sampled BEFORE gzip ran, so on a live log the archive legitimately
139
+ # holds slightly MORE than prev-bytes reports. prev-bytes locates the seam
140
+ # approximately, not exactly. See .docs/brand-log-retention.md.
141
+ : > "$f"
142
+ echo "$(stamp) [logs-rotate] rotated file=$base prev-bytes=$size prev-file=$base.1.gz keep=$KEEP" >> "$f"
143
+ }
144
+
145
+ PURGE_DAYS=7
146
+
147
+ for f in server.log edge.log cloudflared.log check-due-events.log; do
148
+ rotate_file "$LOGS_DIR/$f"
149
+ done
150
+
151
+ # Globbed size targets. nullglob so a brand with none of these yet does not try
152
+ # to rotate a literal '*' path.
153
+ #
154
+ # mcp-*-stderr-*.log: the tee holds a live write stream, so this is the ONLY
155
+ # bound it gets here — see the purge note below for why it is never age-purged.
156
+ #
157
+ # rc-child-*.log: its 16 MiB writer cap does NOT hold. `written`
158
+ # (rc-child-log.ts:33) is in-memory per manager process, so a manager restart
159
+ # resumes appending to the same path with a fresh counter. Observed at 54 MB on
160
+ # sitedesk-code, 3.25x the cap. A long-lived session's file also has a fresh
161
+ # mtime forever, so the age purge below never reaches it — without size rotation
162
+ # it is genuinely unbounded.
163
+ shopt -s nullglob
164
+ for f in "$LOGS_DIR"/mcp-*-stderr-*.log "$LOGS_DIR"/rc-child-*.log; do
165
+ rotate_file "$f"
166
+ done
167
+ shopt -u nullglob
168
+
169
+ # Age purge — rc-child only.
170
+ #
171
+ # rc-child-*.log is one file per session and the set grows without bound (100 on
172
+ # sitedesk-code). Purging it is SAFE because rc-child-log.ts:51 writes with
173
+ # appendFileSync: no fd is held, so a purged file is simply recreated on the
174
+ # next write.
175
+ #
176
+ # mcp-*-stderr-*.log is deliberately NOT purged, though an earlier draft did.
177
+ # The tee (mcp-stderr-tee/src/index.ts:88) opens a createWriteStream at process
178
+ # start and holds it for the process's whole life, writing only when the server
179
+ # emits stderr — which a healthy server does not do for days. So a LIVE file's
180
+ # mtime DOES go stale, and deleting it would send every later stderr write to an
181
+ # unlinked inode: invisible exactly when the fault you are chasing finally
182
+ # appears, and its disk unreclaimed until the process exits. Bounding these by
183
+ # age needs live-vs-dead discrimination, which needs the writer to stop pinning
184
+ # its date at boot — that is Task 1721. Size rotation above is safe regardless,
185
+ # because truncate-in-place is precisely what a held fd tolerates.
186
+ #
187
+ # The pattern is `.log*` so a purged session's rotated .gz generations go too.
188
+ # `find` matches each path by its own mtime — there is no sibling relationship —
189
+ # so a LIVE rc-child's generations also age out at 7 days. That is intended:
190
+ # retention for rc-child is min(KEEP, 7 days), and 7 days of a session's spawn
191
+ # output is the stated bound.
192
+ # One traversal, and the count is what was actually deleted rather than what a
193
+ # separate counting pass saw a moment earlier. `-print` before `-delete` prints
194
+ # each path as it goes.
195
+ purge_old() {
196
+ local pattern="$1" n
197
+ n="$(find "$LOGS_DIR" -maxdepth 1 -name "$pattern" -type f -mtime +"$PURGE_DAYS" -print -delete 2>/dev/null | wc -l | awk '{print $1+0}')"
198
+ [ "$n" -gt 0 ] || return 0
199
+ echo "$(stamp) [logs-rotate] purged pattern=$pattern files=$n older-than-days=$PURGE_DAYS"
200
+ }
201
+
202
+ purge_old 'rc-child-*.log*'
203
+
204
+ exit 0
@@ -194,6 +194,24 @@ log "Resuming tunnel $TUNNEL_ID for $DOMAIN..."
194
194
  # Capture log byte offset before spawning so the verification poll only
195
195
  # matches lines written by THIS invocation, not lines from a prior run.
196
196
  mkdir -p "$LOG_DIR"
197
+
198
+ # Task 1706 — hold the log rotator's lock across offset-capture → verify-poll.
199
+ # logs-rotate.sh truncates cloudflared.log in place when it exceeds 64 MiB; a
200
+ # rotation landing inside this window makes `tail -c "+$LOG_OFFSET"` read past
201
+ # EOF and report a tunnel failure that did not happen. Same mkdir primitive as
202
+ # the rotator (no flock(1) on darwin, and a lock is only a lock if both ends
203
+ # agree). Best-effort: if the lock cannot be taken we proceed anyway rather than
204
+ # refuse to bring the tunnel up — a false-alarm log line is a far smaller
205
+ # failure than a tunnel that never starts.
206
+ ROTATE_LOCK="$LOG_DIR/.rotate.lock"
207
+ ROTATE_LOCK_HELD=0
208
+ if mkdir "$ROTATE_LOCK" 2>/dev/null; then
209
+ ROTATE_LOCK_HELD=1
210
+ trap 'rmdir "$ROTATE_LOCK" 2>/dev/null || true' EXIT
211
+ else
212
+ log "rotate-lock busy — proceeding without it; a concurrent rotation may skew the verify gate"
213
+ fi
214
+
197
215
  LOG_OFFSET=$(wc -c < "$LOG_FILE" 2>/dev/null | awk '{print $1+0}' || echo 0)
198
216
 
199
217
  # Spawn into a SUPERVISED transient service under cloudflared.slice (Task 757).
@@ -264,6 +282,15 @@ else
264
282
  log "ERROR: tunnel service started but no edge connection within ${VERIFY_DEADLINE}s — service=$SERVICE_UNIT"
265
283
  fi
266
284
 
285
+ # Task 1706 — release the rotator's lock. The verify gate above is the only
286
+ # offset-sensitive window, so holding it past here would make a rotator tick
287
+ # skip for no reason.
288
+ if [ "$ROTATE_LOCK_HELD" -eq 1 ]; then
289
+ rmdir "$ROTATE_LOCK" 2>/dev/null || true
290
+ ROTATE_LOCK_HELD=0
291
+ trap - EXIT
292
+ fi
293
+
267
294
  # Update state file: keep pid/startedAt for observability, add serviceUnit as control signal.
268
295
  NEW_STATE=$(echo "$STATE" | jq \
269
296
  --arg unit "$SERVICE_UNIT" \