Skip to content

Commit 395628c

Browse files
mbeczynskiclaude
andauthored
fix: audit 2026-10-10 - open rc port visible and closable, no invisible-backup swaps, FD leak, log trim (#22)
* fix(process): release pipe descriptors as soon as the process exits The timeout timers held the Process and both pipe readers until timeout + 10 s, so every call with a timeout kept two descriptors open long after the child had exited (+100 for 50 calls; 76-92 pipes in the running app against a soft limit of 256). The timers now hold a small box that is emptied in terminationHandler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig * fix(budget): report a usage measurement that stopped coming With a limit set, a failed rclone size left the watchdog comparing the last stored reading with the limit regardless of its age, or raising nothing when there was no reading. The limit then guarded nothing and only the panel turned amber. A reading older than usageStaleAfter (or none at all) is now a watchdog problem, drive-usage-unmeasured. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig * fix(log): trim the rclone log of a running mount without blinding the stall check The log was only rotated when the mount started, and the mount runs for days (257 MB on 10.10, +60 MB a day). The buffer guard now trims it above 100 MB: the last 32 MB go to rclone.log.1 and the live file is truncated (rclone opens it O_APPEND - checked with lsof - so it keeps writing at the new end). The stall and quota checks read the tail of .1 together with the live file when the live file is short, so a trim in the middle of a jam does not read as 'no jam'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig * fix(remote): never trade a visible backup for an invisible one - The panel no longer suggests configure-remote --replace-existing after credentials are saved; on a connected Mac it says to keep the connection (a drive.file sign-in under another client_id cannot see the existing image). - Replacing an existing remote needs --accept-scope-change as well, and even then the new token lists the image (on an in-memory :drive: remote, token via the environment) before rclone.conf is written; if the old connection saw the image and the new one does not, nothing is written. - create-image refuses when Drive shows no image but this Mac remembers one (known-image marker written on create/attach, or the image in the vfs cache): 'check the token', not a second empty backup. - A missing rclone.conf next to cached Drive folders is 'unknown', not 'no remote'; a cached mac-studio folder alone keeps a legacy Mac in mac-studio. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig * fix(rc): make the open remote-control port visible and closable without a reboot A mount started before the private socket survives brew upgrade and keeps taking unauthenticated commands on 127.0.0.1:5572 until the Mac restarts. Only one drive-status line said so. - One check (no socket + mount + something accepts on the port) feeds the window, the watchdog and the CLI. - Window: a red card in every section with the explanation, the prepare-shutdown command and a Restart the mount button (confirmation dialog, disabled during a backup); the green badge goes, the headline and the menu bar say it. - Watchdog: a notification of its own (not a backup failure, so not in the exit code): once, then after 1, 2, 4 and every 7 days; undelivered ones are retried. - restart-mount / the button: refuses while Time Machine runs or when that is unknown; detach and drain as prepare-shutdown, then SIGTERM to the mount job (KeepAlive starts the new code; a job launchd cannot spawn is reloaded once it has exited), wait for the socket, attach. Holds the image lock; any doubt before the stop leaves the mount alone and the image re-attached. - drive-status no longer says 'the mount is not running' when the mount table could not be read (N2); MachineIdentity doc comment back on its symbol (N5); docs use the full cask name (S2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ww8BFyxe5Efb9pvhshhtig --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 4d5c5b0 commit 395628c

32 files changed

Lines changed: 1751 additions & 65 deletions

‎README.md‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,21 @@ It works two ways:
111111

112112
### Upgrading and uninstalling
113113

114-
`brew upgrade` replaces the app without restarting the Google Drive mount.
114+
`brew upgrade --cask renacode/tap/cloudmachine` replaces the app without
115+
restarting the Google Drive mount. (Use the full name: a second tap with a
116+
`cloudmachine` cask, left over from testing, makes the short name ambiguous.)
115117
When Homebrew reopens the app, it reloads the background agents so they run
116118
the new version; `cloudmachine-agent drive-status` shows `Agents: OK`. Changes
117-
to the mount itself wait for its next start - a restart of the Mac, with
118-
`prepare-shutdown` before it. One such change is rclone's control interface
119-
moving to a private socket: until the restart `drive-status` reports
120-
`Remote control: OPEN on 127.0.0.1:5572`. If a
121-
new version does not show up, run `brew update` first - Homebrew refreshes the
122-
tap only now and then. Neither `brew uninstall` nor
119+
to the mount itself wait for its next start. One such change is rclone's
120+
control interface moving to a private socket: until then the old mount takes
121+
commands on `127.0.0.1:5572` without a password, and the app window shows a red
122+
card, the menu bar an alert, and the watchdog a notification (once, then after
123+
1, 2, 4 and every 7 days). **Restart the mount** on that card - or
124+
`cloudmachine-agent restart-mount` - detaches the image, waits until everything
125+
has reached Google Drive, restarts the mount and attaches the image again; it
126+
refuses while Time Machine is backing up. Restarting the Mac after
127+
`prepare-shutdown` does the same. If a new version does not show up, run
128+
`brew update` first - Homebrew refreshes the tap only now and then. Neither `brew uninstall` nor
123129
`--zap` touches the launchd agents or the upload buffer in `~/.cloudmachine`,
124130
which may hold backups that have not reached Google Drive yet. Run
125131
`cloudmachine-agent prepare-shutdown` before uninstalling.
@@ -143,6 +149,12 @@ application itself created. Full `drive` scope would hand out read, write and
143149
disk-image bands needs — especially with `--drive-use-trash=false`, where a
144150
delete has no bin to recover from.
145151

152+
On a Mac that is already connected, keep the connection it has. A new sign-in
153+
with different credentials sees only what it created itself, so the existing
154+
backup would vanish from view; `configure-remote` refuses to replace a working
155+
connection unless told twice, and checks the new token against the image first
156+
(see [setup-cli](docs/setup-cli.md#replacing-the-connection)).
157+
146158
---
147159

148160
## Documentation

‎docs/design.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,21 @@ and loopback is not private: a web page can POST a form there without a CORS
122122
preflight, and rclone does not check `Origin`. It now listens only on
123123
`~/.cloudmachine/run/rc.sock`, in a directory only the owner can enter, which a
124124
browser cannot reach at all. A mount started by an older version keeps the TCP
125-
address until it restarts; `drive-status` says so on its "Remote control" line.
125+
address until it restarts. The window, the menu bar, the watchdog's
126+
notification and `drive-status` all say so - the window, the watchdog and the
127+
CLI from one check: no socket, a mount, and something accepting connections on
128+
the port. `restart-mount` (or the button on that card) closes it without a
129+
restart of the Mac: it is `prepare-shutdown`'s detach-and-drain, then SIGTERM
130+
to the mount job so launchd's `KeepAlive` starts the new code, then the attach -
131+
holding the image lock throughout, and stopping before the mount is touched if
132+
anything has not reached Drive.
133+
134+
**The rclone log is trimmed while the mount runs.** rclone never rotates it,
135+
and a mount runs for days (257 MB on 10 October 2026). Above 100 MB the buffer
136+
guard copies the last 32 MB to `rclone.log.1` and truncates the live file;
137+
rclone opens it with `O_APPEND`, so it carries on at the new end. The stall and
138+
quota checks read the end of `.1` together with the live file, so a trim in the
139+
middle of a jam does not hide it.
126140

127141
The Drive trash stays off, after weighing it as a second line. Bands are
128142
rewritten in place, so uploads would not fill it; deletions would — and the big

‎docs/setup-cli.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,13 @@ pretending otherwise.
7575
replaces the token and the scope, and credentials scoped `drive.file` cannot see
7676
files created by the previous credentials — the backup stays intact but becomes
7777
unreachable, which amounts to the same thing. Back up `~/.config/rclone/rclone.conf`
78-
first and pass `--replace-existing` if you really mean it. The Drive folder of
78+
first and pass `--replace-existing --accept-scope-change` if you really mean
79+
it. Even then nothing is written until the new token has been checked: if the
80+
current connection sees this Mac's image and the new one does not, the
81+
replacement is abandoned and `rclone.conf` stays as it was. The Drive folder of
7982
this Mac does not change with it.
83+
84+
`create-image` refuses in the same spirit: when Google Drive shows no image but
85+
this Mac remembers one (`~/Library/Application Support/CloudMachine/known-image`,
86+
or the image in the upload cache), it says so instead of starting a second,
87+
empty backup next to the invisible one.

‎mac-app/Sources/CloudMachineAgent/CloudMachineAgent.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct CloudMachineAgent: AsyncParsableCommand {
3030
BufferGuard.self,
3131
BackupHealthCommand.self,
3232
PrepareShutdown.self,
33+
RestartMount.self,
3334
DriveStatus.self,
3435
]
3536
)

‎mac-app/Sources/CloudMachineAgent/DriveCommands.swift‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,9 @@ struct BufferGuard: AsyncParsableCommand {
217217

218218
// Forever: the guard has to outlive every backup, not just the first one.
219219
while true {
220+
// The mount runs for days and rclone never trims its own log; the
221+
// guard is the one process that runs as long. A `stat` per tick.
222+
DriveBufferService.trimLogIfLarge()
220223
await guardService.step()
221224
try? await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000)
222225
}
@@ -285,6 +288,17 @@ struct BackupHealthCommand: AsyncParsableCommand {
285288
print(L10n.tr("WAITING (system startup): %@", problem.summary))
286289
}
287290

291+
// Not a backup failure, so not in `report` and not in the exit code -
292+
// but a notification of its own, see `OpenPortAlert`.
293+
let portOpen = DriveBufferService.legacyPortOpenNow()
294+
if portOpen {
295+
print(
296+
L10n.tr(
297+
"WARNING: remote control is OPEN on %@ - run restart-mount (or prepare-shutdown, then restart the Mac).",
298+
DriveBufferService.legacyRCAddress))
299+
}
300+
if !quiet { await OpenPortAlert.report(open: portOpen) }
301+
288302
guard !report.healthy else {
289303
print(L10n.tr("Backup cycle: OK"))
290304
if !quiet { await HealthAlert.report(report) }
@@ -340,6 +354,26 @@ struct PrepareShutdown: AsyncParsableCommand {
340354
}
341355
}
342356

357+
// MARK: - Restarting the mount
358+
359+
struct RestartMount: AsyncParsableCommand {
360+
static let configuration = CommandConfiguration(
361+
commandName: "restart-mount",
362+
abstract: L10n.tr(
363+
"Restarts the Google Drive mount on the private socket: detaches the image, waits for the upload, restarts the mount and attaches the image again."
364+
))
365+
366+
func run() async throws {
367+
print(
368+
L10n.tr(
369+
"Detaching the image and waiting for the upload, then restarting the mount. This can take several minutes; Time Machine has no destination meanwhile."
370+
))
371+
let result = await MountRestart.perform()
372+
print(result.message)
373+
if !result.succeeded { throw ExitCode(1) }
374+
}
375+
}
376+
343377
// MARK: - Status
344378

345379
struct DriveStatus: AsyncParsableCommand {

‎mac-app/Sources/CloudMachineAgent/SetupCommands.swift‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ struct ConfigureRemote: AsyncParsableCommand {
3333
)
3434
var replaceExisting = false
3535

36+
@Flag(
37+
name: .long,
38+
help:
39+
ArgumentHelp(
40+
L10n.tr(
41+
"With --replace-existing: confirms that the new sign-in (scope 'drive.file') may not see the existing backup. The new token is still checked against the image first."
42+
))
43+
)
44+
var acceptScopeChange = false
45+
3646
@Option(
3747
name: .long,
3848
help: ArgumentHelp(
@@ -44,7 +54,8 @@ struct ConfigureRemote: AsyncParsableCommand {
4454
func run() async throws {
4555
let (config, key) = await CLIContext.load()
4656
let result = await RemoteConfigurer.connect(
47-
config: config, machineKey: key, replaceExisting: replaceExisting, folder: folder)
57+
config: config, machineKey: key, replaceExisting: replaceExisting,
58+
acceptScopeChange: acceptScopeChange, folder: folder)
4859
print(result.message)
4960
if !result.succeeded { throw ExitCode.failure }
5061
}

‎mac-app/Sources/CloudMachineApp/Models/AppStatus.swift‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,11 @@ final class AppStatus: ObservableObject {
222222
/// Whether this Mac's image exists on the mounted Drive; `nil` while the
223223
/// Drive is not mounted, because then nobody can know.
224224
@Published var imageExists: Bool?
225+
/// The Drive mount still takes commands on the open TCP port - a mount from
226+
/// before the private socket that survived the upgrade (see
227+
/// `DriveBufferService.legacyPortOpen`). Until 11.10.2026 only one line of
228+
/// `drive-status` said so; the window was green.
229+
@Published var remoteControlOpen: Bool = false
225230
@Published var isBusy: Bool = false
226231
@Published var busyLabel: String = ""
227232
@Published var errorMessage: String?
@@ -263,6 +268,11 @@ final class AppStatus: ObservableObject {
263268
// copy; that is precisely why `UploadState` puts them ahead of the daily limit.
264269
let upload = buffer.uploadState
265270
if !upload.isNominal { return upload.headline }
271+
// Backups work in this state, but any web page can delete them - so it
272+
// comes before every sentence that sounds like "all is well".
273+
if remoteControlOpen {
274+
return L10n.tr("Drive remote control is open - restart the mount")
275+
}
266276
if backupProgress != nil { return L10n.tr("Backup in progress") }
267277
// The state of the devices can be flawless while there has been no backup for two days.
268278
// This sentence must come BEFORE "Ready", otherwise the headline contradicts the
@@ -290,14 +300,24 @@ final class AppStatus: ObservableObject {
290300
/// and each of them can be met when no backup has been made for two days.
291301
/// The green badge is meant to say "the data is safe", and that follows
292302
/// solely from a backup having BEEN MADE - not from the disk being attached.
303+
///
304+
/// THIRD, from 11.10.2026: `remoteControlOpen`. A backup that any web page
305+
/// can delete is not safe, however fresh it is.
293306
var healthy: Bool {
294307
guard case .ready = dependencyState, remoteConfigured, buffer.mounted, buffer.imageAttached,
295308
case .registered = timeMachineState, buffer.uploadState.isNominal,
296-
backupCycle.isFresh()
309+
backupCycle.isFresh(), !remoteControlOpen
297310
else { return false }
298311
return true
299312
}
300313

314+
/// Whether "Restart the mount" may be offered: the port is open, nothing
315+
/// else is running, and Time Machine is not backing up. `MountRestart`
316+
/// checks the backup itself again - this only keeps the button honest.
317+
var canRestartMount: Bool {
318+
remoteControlOpen && !isBusy && backupProgress == nil
319+
}
320+
301321
/// Whether "Back up now" can do anything: the backup has somewhere to go.
302322
///
303323
/// NOT `healthy`, which until 09.10.2026 decided this too. `healthy` also

‎mac-app/Sources/CloudMachineApp/Services/CloudMachineController.swift‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,16 +136,17 @@ final class CloudMachineController: ObservableObject {
136136
/// Saves the credentials and refreshes the state.
137137
///
138138
/// Does not reconfigure the remote: a token issued for the old `client_id` keeps
139-
/// working, so entering new values alone changes NOTHING until
140-
/// `configure-remote --replace-existing` is run. We say so plainly.
139+
/// working, so entering new values alone changes NOTHING on a connected Mac -
140+
/// and must not lead to replacing the connection there (see
141+
/// `RemoteConfigurer.credentialsSavedMessage`).
141142
func saveCredentials(clientID: String, clientSecret: String) async -> String {
142143
do {
143144
try await RemoteConfigurer.storeCredentials(
144145
clientID: clientID, clientSecret: clientSecret)
145146
await refreshCredentials()
146-
return L10n.tr(
147-
"Saved in the Keychain. Note: the existing connection still uses the old client_id - to use the new one, run configure-remote --replace-existing."
148-
)
147+
return RemoteConfigurer.credentialsSavedMessage(
148+
remoteConfigured: await RemoteConfigurer.isConfigured(
149+
remoteName: DriveBufferService.remoteName))
149150
} catch {
150151
return L10n.tr("Not saved: %@", error.localizedDescription)
151152
}
@@ -183,6 +184,7 @@ final class CloudMachineController: ObservableObject {
183184
buffer.outOfSpace = stats.outOfSpace
184185
}
185186
status.buffer = buffer
187+
status.remoteControlOpen = DriveBufferService.legacyPortOpenNow()
186188
status.imageExists =
187189
buffer.mounted
188190
? FileManager.default.fileExists(atPath: BackupImageService.imagePath.path) : nil
@@ -338,6 +340,15 @@ final class CloudMachineController: ObservableObject {
338340
}
339341
}
340342

343+
/// Restarts the Drive mount so it comes back on the private socket. The
344+
/// view asks for confirmation first; `MountRestart` refuses by itself
345+
/// while Time Machine is backing up.
346+
func restartMount() async {
347+
await run(L10n.tr("Restarting the Google Drive mount"), log: "Restarting the Drive mount") {
348+
await MountRestart.perform()
349+
}
350+
}
351+
341352
func verifyImage() async {
342353
await run(L10n.tr("Checking image consistency"), log: "Checking image consistency") {
343354
await BackupImageService.verify()

‎mac-app/Sources/CloudMachineApp/Views/DashboardView.swift‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ private struct DashboardContent: View {
7070
@State private var driveFolder = ""
7171
/// The limit being typed; empty = show the stored one.
7272
@State private var limitText = ""
73+
@State private var confirmingMountRestart = false
7374

7475
var body: some View {
7576
HStack(spacing: 0) {
@@ -95,6 +96,12 @@ private struct DashboardContent: View {
9596
errorBanner(error)
9697
}
9798

99+
// Every section too: this is not a state of one card, but a door
100+
// left open on the whole backup.
101+
if status.remoteControlOpen {
102+
openRemoteControlCard
103+
}
104+
98105
switch section {
99106
case .overview: overviewSection
100107
case .backups: backupsSection
@@ -571,6 +578,70 @@ private struct DashboardContent: View {
571578
.toneCard(.danger, cornerRadius: 12, padding: 14)
572579
}
573580

581+
// MARK: - Open remote control
582+
583+
/// The mount still takes commands on the open TCP port. Says what that
584+
/// means, and offers the one fix that does not need a restart of the Mac.
585+
private var openRemoteControlCard: some View {
586+
VStack(alignment: .leading, spacing: 12) {
587+
cardTitle(
588+
L10n.tr("Remote control of the Drive mount is open"), systemImage: "lock.open.fill",
589+
tone: .danger)
590+
591+
Text(
592+
L10n.tr(
593+
"The mount was started by an older version of CloudMachine and still takes commands on %@ without a password. Any web page open in a browser can send it one - including deleting the backup on Google Drive, with no trash to restore it from. It moves to a private socket the next time it starts.",
594+
DriveBufferService.legacyRCAddress)
595+
)
596+
.font(.system(size: 13))
597+
.foregroundStyle(RenaCodeTheme.textMain)
598+
.fixedSize(horizontal: false, vertical: true)
599+
600+
Text(
601+
L10n.tr(
602+
"Restart the mount: the image is detached, everything waits until it has reached Google Drive, the mount starts again and the image is attached. It takes a few minutes, during which Time Machine has no destination. Or prepare the shutdown and restart the Mac:"
603+
)
604+
)
605+
.font(.system(size: 13))
606+
.foregroundStyle(RenaCodeTheme.textMuted)
607+
.fixedSize(horizontal: false, vertical: true)
608+
609+
commandBox("\(CMPaths.agentBinaryPath?.path ?? "cloudmachine-agent") prepare-shutdown")
610+
611+
HStack(spacing: 12) {
612+
Button(action: { confirmingMountRestart = true }) {
613+
HStack(spacing: 6) {
614+
Image(systemName: "arrow.triangle.2.circlepath")
615+
Text(L10n.tr("Restart the mount"))
616+
}
617+
}
618+
.buttonStyle(PrimaryGradientButtonStyle())
619+
.disabled(!status.canRestartMount)
620+
621+
if status.backupProgress != nil {
622+
Text(L10n.tr("Wait until the backup has finished."))
623+
.font(.system(size: 12))
624+
.foregroundStyle(RenaCodeTheme.textMuted)
625+
}
626+
Spacer()
627+
}
628+
}
629+
.toneCard(.danger)
630+
.confirmationDialog(
631+
L10n.tr("Restart the Google Drive mount now?"), isPresented: $confirmingMountRestart
632+
) {
633+
Button(L10n.tr("Restart the mount"), role: .destructive) {
634+
Task { await controller.restartMount() }
635+
}
636+
Button(L10n.tr("Cancel"), role: .cancel) {}
637+
} message: {
638+
Text(
639+
L10n.tr(
640+
"Time Machine loses its destination for a few minutes. Nothing is stopped until everything waiting has reached Google Drive; if that does not happen, the mount is left as it is."
641+
))
642+
}
643+
}
644+
574645
// MARK: - Backups
575646

576647
private var backupCycleTone: StatusTone {

‎mac-app/Sources/CloudMachineApp/Views/MenuBarContentView.swift‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,21 @@ private struct MenuBarPanel: View {
8787
}
8888
.accessibilityElement(children: .combine)
8989

90+
// The headline says it only while nothing worse is going on; this row
91+
// stays as long as the port is open.
92+
if status.remoteControlOpen,
93+
status.headline != L10n.tr("Drive remote control is open - restart the mount")
94+
{
95+
HStack(alignment: .firstTextBaseline, spacing: 8) {
96+
StatusDot(.danger)
97+
Text(L10n.tr("Drive remote control is open - restart the mount"))
98+
.font(.system(size: 12, weight: .medium))
99+
.foregroundStyle(StatusTone.danger.color)
100+
.fixedSize(horizontal: false, vertical: true)
101+
}
102+
.accessibilityElement(children: .combine)
103+
}
104+
90105
// Progress of the running backup
91106
if let progress = status.backupProgress, let percent = progress.percent {
92107
VStack(alignment: .leading, spacing: 6) {

0 commit comments

Comments
 (0)