From 1a5a69da0922239daef451fa894e6c6411247b49 Mon Sep 17 00:00:00 2001 From: seroxdesign Date: Tue, 29 Sep 2026 16:13:20 -0400 Subject: [PATCH] mobile: keep computer tokens device-only; harden prune and npm publish Review of #400 found that cross-device computer sync cannot work in the shipping app (iCloud KVS is not entitled; the identifier is commented out in project.yml pending the iCloud container) while it still moved Kittylitter tokens into iCloud Keychain. Remove the sync until it can be redesigned and entitled: - Remove the computer ledger (cloud_sync::computers), its KVS key, and the SavedServerStore/CloudKVSBridge wiring. - Alleycat tokens are device-only again. Tokens a pre-release build put in iCloud Keychain are moved back: the device-only copy is written before the iCloud copy is deleted. saveToken adds or updates in place, so a failed write never removes a working token. The in-memory cache stays. - prune-rust-target.sh only acts on a Cargo target (named `target` with Cargo's CACHEDIR.TAG), rejects a non-numeric cap, skips while cargo or rustc is running, and never removes the target directory itself. - release.yml: publish-npm runs in the `npm-publish` environment, which GitHub restricts to `main`, so a dispatch from another branch cannot obtain an npm publish token. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release.yml | 4 + .../Models/AlleycatCredentialStore.swift | 93 +++--- .../Litter/Models/CloudKVSBridge.swift | 4 +- .../Litter/Models/SavedServerStore.swift | 88 ------ .../Litter/Views/AlleycatAddServerSheet.swift | 8 +- .../LitterTests/SavedServerStoreTests.swift | 43 --- .../src/cloud_sync/computers.rs | 270 ------------------ .../codex-mobile-client/src/cloud_sync/mod.rs | 5 - tools/scripts/prune-rust-target.sh | 31 +- 9 files changed, 72 insertions(+), 474 deletions(-) delete mode 100644 shared/rust-bridge/codex-mobile-client/src/cloud_sync/computers.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25ee9d4de..a5dee4045 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -422,6 +422,10 @@ jobs: - plan - host runs-on: "ubuntu-22.04" + # npm trusts this repo's release.yml only when it runs in the npm-publish + # environment, which GitHub restricts to `main`. A dispatch from any + # other branch cannot get a publish token. + environment: npm-publish permissions: "id-token": "write" "contents": "read" diff --git a/apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift b/apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift index ff901413c..5ee6e0629 100644 --- a/apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift +++ b/apps/ios/Sources/Litter/Models/AlleycatCredentialStore.swift @@ -37,6 +37,8 @@ final class AlleycatCredentialStore { cacheLock.unlock() if let cached { return cached } + // Match device-only and iCloud items: a pre-release build could move + // tokens to iCloud Keychain, and those must still be found. let query = baseQuery(nodeId: nodeId).merging([ kSecAttrSynchronizable as String: kSecAttrSynchronizableAny, kSecReturnData as String: true, @@ -53,6 +55,9 @@ final class AlleycatCredentialStore { let token = String(data: data, encoding: .utf8), !token.isEmpty else { throw AlleycatCredentialStoreError.decodingFailed } + if (attributes[kSecAttrSynchronizable as String] as? Bool) == true { + moveToDeviceOnly(token, nodeId: nodeId) + } cacheLock.lock() tokenCache[key] = token cacheLock.unlock() @@ -64,24 +69,30 @@ final class AlleycatCredentialStore { } } - /// Saves the token device-only by default. Pass `synchronizable: true` - /// only for computers that sync across devices (Kittylitter hosts, which - /// authenticate by token alone): the token then goes to iCloud Keychain, - /// which is end-to-end encrypted, so the user's other devices can connect. - func saveToken(_ token: String, nodeId: String, synchronizable: Bool = false) throws { + /// Tokens are device-only: `AfterFirstUnlockThisDeviceOnly`, never + /// synchronizable. Adds the item, or updates it in place if it exists, + /// so a failed write never removes a working token. + func saveToken(_ token: String, nodeId: String) throws { guard let data = token.data(using: .utf8) else { throw AlleycatCredentialStoreError.encodingFailed } - try deleteKeychainItems(nodeId: nodeId) - let attributes = baseQuery(nodeId: nodeId).merging([ - kSecAttrSynchronizable as String: synchronizable, - kSecAttrAccessible as String: synchronizable - ? kSecAttrAccessibleAfterFirstUnlock - : kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + let query = baseQuery(nodeId: nodeId).merging([ + kSecAttrSynchronizable as String: false + ]) { _, new in new } + let attributes = query.merging([ + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecValueData as String: data ]) { _, new in new } - let status = SecItemAdd(attributes as CFDictionary, nil) + + var status = SecItemAdd(attributes as CFDictionary, nil) + if status == errSecDuplicateItem { + let updates: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + status = SecItemUpdate(query as CFDictionary, updates as CFDictionary) + } guard status == errSecSuccess else { throw AlleycatCredentialStoreError.keychain(status) } @@ -90,56 +101,30 @@ final class AlleycatCredentialStore { cacheLock.unlock() } - private var synchronizedNodeIds: Set = [] - - /// Moves a device-only token (saved before iCloud sync) to iCloud - /// Keychain. Called for computers that sync, so a computer that appears - /// on another device also brings its credential. No-op once done. - func makeSynchronizable(nodeId: String) { - let key = normalizedNodeId(nodeId) - cacheLock.lock() - let done = synchronizedNodeIds.contains(key) - cacheLock.unlock() - guard !done else { return } - - let query = baseQuery(nodeId: nodeId).merging([ - kSecAttrSynchronizable as String: false, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne + /// Moves a token that a pre-release build put in iCloud Keychain back to + /// device-only. Writes the device-only copy first and deletes the iCloud + /// copy only once that succeeded, so the token is never lost. + private func moveToDeviceOnly(_ token: String, nodeId: String) { + do { + try saveToken(token, nodeId: nodeId) + } catch { + LLog.error("alleycat", "moving token to device-only keychain failed", error: error) + return + } + let synced = baseQuery(nodeId: nodeId).merging([ + kSecAttrSynchronizable as String: true ]) { _, new in new } - var item: CFTypeRef? - if SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, - let data = item as? Data, - let token = String(data: data, encoding: .utf8), !token.isEmpty { - do { - try saveToken(token, nodeId: nodeId, synchronizable: true) - } catch { - LLog.error("alleycat", "moving token to iCloud Keychain failed", error: error) - return - } + let status = SecItemDelete(synced as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + LLog.error("alleycat", "deleting iCloud keychain token failed", error: AlleycatCredentialStoreError.keychain(status)) } - cacheLock.lock() - synchronizedNodeIds.insert(key) - cacheLock.unlock() - } - - /// Drop cached tokens so the next read sees iCloud Keychain changes made - /// on another device (for example a re-pair with a new token). - func clearCache() { - cacheLock.lock() - tokenCache.removeAll() - cacheLock.unlock() } func deleteToken(nodeId: String) throws { cacheLock.lock() tokenCache[normalizedNodeId(nodeId)] = nil cacheLock.unlock() - try deleteKeychainItems(nodeId: nodeId) - } - - /// Deletes both the device-only and the iCloud copy. - private func deleteKeychainItems(nodeId: String) throws { + // Both the device-only item and any iCloud copy. let query = baseQuery(nodeId: nodeId).merging([ kSecAttrSynchronizable as String: kSecAttrSynchronizableAny ]) { _, new in new } diff --git a/apps/ios/Sources/Litter/Models/CloudKVSBridge.swift b/apps/ios/Sources/Litter/Models/CloudKVSBridge.swift index d1efd44c2..ebd546021 100644 --- a/apps/ios/Sources/Litter/Models/CloudKVSBridge.swift +++ b/apps/ios/Sources/Litter/Models/CloudKVSBridge.swift @@ -78,7 +78,6 @@ final class CloudKVSBridge { // initial sync. store.synchronize() applyEnvelopeFromStore() - SavedServerStore.syncWithCloud() scheduleExport() } @@ -122,8 +121,7 @@ final class CloudKVSBridge { ) applyWritebacks(writebacks) lastAppliedEnvelopeHash = hash - SavedServerStore.syncWithCloud() - NotificationCenter.default.post(name: .litterThreadPreferencesDidChange, object: nil) + NotificationCenter.default.post(name: .litterThreadPreferencesDidChange, object: nil) } catch { LLog.warn( "cloud_sync", diff --git a/apps/ios/Sources/Litter/Models/SavedServerStore.swift b/apps/ios/Sources/Litter/Models/SavedServerStore.swift index 6d87ee1a2..42d3c8c58 100644 --- a/apps/ios/Sources/Litter/Models/SavedServerStore.swift +++ b/apps/ios/Sources/Litter/Models/SavedServerStore.swift @@ -17,10 +17,6 @@ enum SavedServerStore { private static var cachedServers: [SavedServer] = [] static func save(_ servers: [SavedServer]) { - persist(reconcileWithCloud(servers)) - } - - private static func persist(_ servers: [SavedServer]) { guard let data = try? JSONEncoder().encode(servers) else { return } UserDefaults.standard.set(data, forKey: savedServersKey) cachedRaw = data @@ -28,90 +24,6 @@ enum SavedServerStore { NotificationCenter.default.post(name: .litterSavedServersDidChange, object: nil) } - // MARK: - iCloud sync - - /// Ledger shared with the user's other devices through `CloudKVSBridge`. - private static let syncedComputersKey = "litter.syncedComputers" - /// This device's copy of the ledger. Not synced. - private static let computerLedgerKey = "litter.syncedComputers.ledger" - - /// Computers that any of the user's devices can reach with what syncs: - /// Kittylitter hosts (token-only auth; tokens sync via iCloud Keychain) - /// and direct Codex URLs. Local Studio grants are bound to one device's - /// key, SSH keys never leave the device, and ChatGPT-connected computers - /// need that device's own sign-in, so those stay local. - static func isCloudSyncable(_ server: SavedServer) -> Bool { - guard server.rememberedByUser, server.source != .local, server.source != .ssh else { return false } - guard !server.id.hasPrefix("slingshot-") else { return false } - guard server.preferredConnectionMode != .ssh, server.alleycatAgentWire != "ssh-bridge" else { return false } - if server.alleycatNodeId != nil { - return !server.id.hasPrefix("alleycat:local-studio:") - } - return server.alleycatHost == nil - } - - /// Re-merge after another device changed the shared ledger. - static func syncWithCloud() { - AlleycatCredentialStore.shared.clearCache() - let current = load() - let merged = reconcileWithCloud(current) - if merged != current { - persist(merged) - } - } - - /// Records this device's syncable computers in the ledger, merges in the - /// ledger from other devices, and returns the saved list the merge - /// implies. The merge policy lives in Rust (`cloud_sync::computers`). - private static func reconcileWithCloud(_ servers: [SavedServer]) -> [SavedServer] { - let defaults = UserDefaults.standard - let encoder = JSONEncoder() - encoder.outputFormatting = .sortedKeys - for server in servers where isCloudSyncable(server) { - if let nodeId = server.alleycatNodeId { - AlleycatCredentialStore.shared.makeSynchronizable(nodeId: nodeId) - } - } - let local = servers.filter(isCloudSyncable).compactMap { server -> SyncedComputer? in - guard let data = try? encoder.encode(server), - let json = String(data: data, encoding: .utf8) else { return nil } - return SyncedComputer(id: server.id, payloadJson: json) - } - let remote = defaults.string(forKey: syncedComputersKey) - let result = cloudSyncReconcileComputers( - ledgerJson: defaults.string(forKey: computerLedgerKey), - local: local, - remoteJson: remote, - nowMs: Int64(Date().timeIntervalSince1970 * 1000) - ) - defaults.set(result.ledgerJson, forKey: computerLedgerKey) - if remote != result.ledgerJson { - // Observed by CloudKVSBridge, which pushes it to other devices. - defaults.set(result.ledgerJson, forKey: syncedComputersKey) - } - - let decoder = JSONDecoder() - var synced: [String: SavedServer] = [:] - for computer in result.computers { - guard let data = computer.payloadJson.data(using: .utf8), - let server = try? decoder.decode(SavedServer.self, from: data) else { continue } - synced[computer.id] = server - } - // Keep this device's order; computers added elsewhere go last. - var merged: [SavedServer] = [] - for server in servers { - if isCloudSyncable(server) { - if let updated = synced.removeValue(forKey: server.id) { - merged.append(updated) - } - } else { - merged.append(server) - } - } - merged.append(contentsOf: synced.values.sorted { $0.name < $1.name }) - return merged - } - static func load() -> [SavedServer] { guard let data = UserDefaults.standard.data(forKey: savedServersKey) else { cachedRaw = nil diff --git a/apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift b/apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift index 65ea1d17e..b24ea7417 100644 --- a/apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift +++ b/apps/ios/Sources/Litter/Views/AlleycatAddServerSheet.swift @@ -494,13 +494,7 @@ struct AlleycatAddServerSheet: View { // saved-server record. Reconnect re-reads the token and surfaces // a re-pair prompt if it is genuinely missing. do { - // Kittylitter computers sync to the user's other devices; - // Local Studio grants are bound to this device's key. - try AlleycatCredentialStore.shared.saveToken( - params.token, - nodeId: params.nodeId, - synchronizable: pairingMode == .kittylitter - ) + try AlleycatCredentialStore.shared.saveToken(params.token, nodeId: params.nodeId) } catch { LLog.error("alleycat", "keychain save failed after successful pair", error: error) } diff --git a/apps/ios/Tests/LitterTests/SavedServerStoreTests.swift b/apps/ios/Tests/LitterTests/SavedServerStoreTests.swift index 6ed25c91c..727588b50 100644 --- a/apps/ios/Tests/LitterTests/SavedServerStoreTests.swift +++ b/apps/ios/Tests/LitterTests/SavedServerStoreTests.swift @@ -24,49 +24,6 @@ final class SavedServerStoreTests: XCTestCase { ) } - func testOnlyKittylitterAndDirectURLComputersSyncAcrossDevices() { - XCTAssertTrue(SavedServerStore.isCloudSyncable(alleycatServer(id: "alleycat:node-a", nodeId: "node-a"))) - XCTAssertTrue(SavedServerStore.isCloudSyncable(server(id: "manual-ws", websocketURL: "wss://box.example:8390"))) - - // Local Studio grants are bound to one device's key. - XCTAssertFalse(SavedServerStore.isCloudSyncable( - alleycatServer(id: "alleycat:local-studio:node-a", nodeId: "node-a") - )) - // SSH keys never leave the device. - XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "ssh-box", source: .ssh))) - XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "manual-ssh-box:22", mode: .ssh))) - // ChatGPT-connected computers need that device's own sign-in. - XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "slingshot-env", websocketURL: "wss://chatgpt.com/x"))) - // This device, and computers the user never chose to keep. - XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "local", source: .local))) - XCTAssertFalse(SavedServerStore.isCloudSyncable(server(id: "manual-ws", remembered: false))) - } - - private func server( - id: String, - source: ServerSource = .manual, - mode: PreferredConnectionMode? = .directCodex, - websocketURL: String? = nil, - remembered: Bool = true - ) -> SavedServer { - SavedServer( - id: id, - name: id, - hostname: "box.example", - port: 8390, - codexPorts: [], - sshPort: nil, - source: source, - hasCodexServer: true, - wakeMAC: nil, - preferredConnectionMode: mode, - preferredCodexPort: nil, - sshPortForwardingEnabled: nil, - websocketURL: websocketURL, - rememberedByUser: remembered - ) - } - private func alleycatServer(id: String, nodeId: String) -> SavedServer { SavedServer( id: id, diff --git a/shared/rust-bridge/codex-mobile-client/src/cloud_sync/computers.rs b/shared/rust-bridge/codex-mobile-client/src/cloud_sync/computers.rs deleted file mode 100644 index 042ee7f3d..000000000 --- a/shared/rust-bridge/codex-mobile-client/src/cloud_sync/computers.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! Cross-device sync for saved computers. -//! -//! The platform owns the saved-server schema, so each computer travels as an -//! opaque JSON payload keyed by its server id. This module owns the merge: -//! a last-write-wins element set. Every id carries the time it was last -//! written and, if removed, the time it was removed; the later of the two -//! decides whether it is live. Two devices that write concurrently each keep -//! the other's entries after merging, and removals propagate instead of being -//! resurrected by a device that has not seen them yet. -//! -//! The ledger is serialized as canonical JSON (sorted keys) so the platform -//! can skip a publish when the merged ledger equals what it just received. - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - -/// Tombstones older than this are dropped. A device offline for longer than -/// this could resurrect a removed computer, which is an acceptable trade for -/// keeping the ledger small. -const TOMBSTONE_RETENTION_MS: i64 = 90 * 24 * 60 * 60 * 1000; - -#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] -pub struct SyncedComputer { - pub id: String, - /// Platform-encoded saved-server JSON. Compared byte-for-byte, so the - /// platform must encode deterministically (sorted keys). - pub payload_json: String, -} - -#[derive(Debug, Clone, uniffi::Record)] -pub struct ComputerSyncResult { - /// Canonical ledger JSON to persist locally and publish to other devices. - pub ledger_json: String, - /// Computers that are live after the merge, sorted by id. - pub computers: Vec, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -struct Ledger { - #[serde(default)] - entries: BTreeMap, - #[serde(default)] - removed: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -struct Entry { - payload: String, - at: i64, -} - -impl Ledger { - fn parse(json: Option<&str>) -> Self { - json.filter(|s| !s.trim().is_empty()) - .and_then(|s| serde_json::from_str(s).ok()) - .unwrap_or_default() - } - - fn is_live(&self, id: &str) -> bool { - match (self.entries.get(id), self.removed.get(id)) { - (Some(entry), Some(removed_at)) => entry.at > *removed_at, - (Some(_), None) => true, - _ => false, - } - } - - /// Record the device's current computers: new or changed payloads are - /// stamped `now`, and live entries missing from `local` are removed. - fn record_local(&mut self, local: &[SyncedComputer], now_ms: i64) { - for computer in local { - let unchanged = self.is_live(&computer.id) - && self - .entries - .get(&computer.id) - .is_some_and(|entry| entry.payload == computer.payload_json); - if !unchanged { - self.entries.insert( - computer.id.clone(), - Entry { - payload: computer.payload_json.clone(), - at: now_ms, - }, - ); - } - } - let live_ids: Vec = self - .entries - .keys() - .filter(|id| self.is_live(id)) - .cloned() - .collect(); - for id in live_ids { - if !local.iter().any(|computer| computer.id == id) { - self.removed.insert(id, now_ms); - } - } - } - - fn merge(&mut self, other: Ledger) { - for (id, entry) in other.entries { - let newer = self.entries.get(&id).is_none_or(|mine| entry.at > mine.at); - if newer { - self.entries.insert(id, entry); - } - } - for (id, removed_at) in other.removed { - let newer = self.removed.get(&id).is_none_or(|mine| removed_at > *mine); - if newer { - self.removed.insert(id, removed_at); - } - } - } - - fn compact(&mut self, now_ms: i64) { - let dead: Vec = self - .entries - .keys() - .filter(|id| !self.is_live(id)) - .cloned() - .collect(); - for id in dead { - self.entries.remove(&id); - } - self.removed - .retain(|_, removed_at| now_ms - *removed_at < TOMBSTONE_RETENTION_MS); - } - - fn live(&self) -> Vec { - self.entries - .iter() - .filter(|(id, _)| self.is_live(id)) - .map(|(id, entry)| SyncedComputer { - id: id.clone(), - payload_json: entry.payload.clone(), - }) - .collect() - } -} - -pub fn reconcile( - ledger_json: Option<&str>, - local: &[SyncedComputer], - remote_json: Option<&str>, - now_ms: i64, -) -> ComputerSyncResult { - let mut ledger = Ledger::parse(ledger_json); - ledger.record_local(local, now_ms); - if remote_json.is_some() { - ledger.merge(Ledger::parse(remote_json)); - } - ledger.compact(now_ms); - ComputerSyncResult { - ledger_json: serde_json::to_string(&ledger).unwrap_or_default(), - computers: ledger.live(), - } -} - -/// Merge this device's saved computers with the ledger from other devices. -/// -/// * `ledger_json`: this device's last ledger (None on first run). -/// * `local`: the computers currently saved on this device. -/// * `remote_json`: the ledger received from other devices, if any. -/// -/// Returns the merged ledger and the computers that should now be saved. -#[uniffi::export] -pub fn cloud_sync_reconcile_computers( - ledger_json: Option, - local: Vec, - remote_json: Option, - now_ms: i64, -) -> ComputerSyncResult { - reconcile( - ledger_json.as_deref(), - &local, - remote_json.as_deref(), - now_ms, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn computer(id: &str, payload: &str) -> SyncedComputer { - SyncedComputer { - id: id.to_string(), - payload_json: payload.to_string(), - } - } - - fn ids(result: &ComputerSyncResult) -> Vec<&str> { - result.computers.iter().map(|c| c.id.as_str()).collect() - } - - #[test] - fn first_run_keeps_local_computers() { - let result = reconcile(None, &[computer("a", "1")], None, 10); - assert_eq!(ids(&result), vec!["a"]); - } - - #[test] - fn concurrent_adds_on_two_devices_both_survive() { - let a = reconcile(None, &[computer("mac", "m")], None, 10); - let b = reconcile(None, &[computer("pc", "p")], None, 11); - // Device B receives A's ledger after writing its own. - let merged = reconcile( - Some(&b.ledger_json), - &b.computers, - Some(&a.ledger_json), - 12, - ); - assert_eq!(ids(&merged), vec!["mac", "pc"]); - } - - #[test] - fn late_joining_device_does_not_wipe_existing_computers() { - let phone = reconcile(None, &[computer("mac", "m")], None, 10); - // A new iPad with nothing saved publishes first, then sees the phone. - let ipad = reconcile(None, &[], None, 20); - let ipad = reconcile(Some(&ipad.ledger_json), &[], Some(&phone.ledger_json), 21); - assert_eq!(ids(&ipad), vec!["mac"]); - } - - #[test] - fn removal_propagates_and_is_not_resurrected() { - let a = reconcile(None, &[computer("mac", "m")], None, 10); - let b = reconcile(None, &[], Some(&a.ledger_json), 11); - assert_eq!(ids(&b), vec!["mac"]); - // A removes the computer. - let a = reconcile(Some(&a.ledger_json), &[], None, 20); - assert!(a.computers.is_empty()); - // B still has it saved locally but must drop it after merging. - let b = reconcile(Some(&b.ledger_json), &b.computers, Some(&a.ledger_json), 21); - assert!(b.computers.is_empty()); - } - - #[test] - fn re_adding_after_removal_wins() { - let a = reconcile(None, &[computer("mac", "m")], None, 10); - let a = reconcile(Some(&a.ledger_json), &[], None, 20); - let a = reconcile(Some(&a.ledger_json), &[computer("mac", "m2")], None, 30); - assert_eq!(ids(&a), vec!["mac"]); - assert_eq!(a.computers[0].payload_json, "m2"); - } - - #[test] - fn rename_on_one_device_reaches_the_other() { - let a = reconcile(None, &[computer("mac", "old")], None, 10); - let b = reconcile(None, &[], Some(&a.ledger_json), 11); - let a = reconcile(Some(&a.ledger_json), &[computer("mac", "new")], None, 20); - let b = reconcile(Some(&b.ledger_json), &b.computers, Some(&a.ledger_json), 21); - assert_eq!(b.computers[0].payload_json, "new"); - } - - #[test] - fn unchanged_state_produces_identical_ledger() { - let a = reconcile(None, &[computer("mac", "m")], None, 10); - let again = reconcile(Some(&a.ledger_json), &a.computers, Some(&a.ledger_json), 50); - assert_eq!(a.ledger_json, again.ledger_json); - } - - #[test] - fn old_tombstones_are_compacted() { - let a = reconcile(None, &[computer("mac", "m")], None, 0); - let a = reconcile(Some(&a.ledger_json), &[], None, 1); - let later = reconcile(Some(&a.ledger_json), &[], None, TOMBSTONE_RETENTION_MS + 10); - assert_eq!(later.ledger_json, r#"{"entries":{},"removed":{}}"#); - } -} diff --git a/shared/rust-bridge/codex-mobile-client/src/cloud_sync/mod.rs b/shared/rust-bridge/codex-mobile-client/src/cloud_sync/mod.rs index 840bcb73d..b4c7c8cdc 100644 --- a/shared/rust-bridge/codex-mobile-client/src/cloud_sync/mod.rs +++ b/shared/rust-bridge/codex-mobile-client/src/cloud_sync/mod.rs @@ -26,8 +26,6 @@ //! `PLATFORM_KEYS`). Anything not in that set is round-tripped through the //! Rust-owned `mobile_prefs.json` side of the envelope. -pub mod computers; - use std::collections::HashMap; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; @@ -60,9 +58,6 @@ const PLATFORM_KEYS: &[&str] = &[ "collapseTurns", "litter.debugSettings", "litter.experimentalFeatures", - // Saved-computer ledger (see `computers.rs`). A string holding canonical - // ledger JSON; the platform merges it rather than taking it verbatim. - "litter.syncedComputers", ]; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/tools/scripts/prune-rust-target.sh b/tools/scripts/prune-rust-target.sh index baaad01a5..f4f6413c0 100755 --- a/tools/scripts/prune-rust-target.sh +++ b/tools/scripts/prune-rust-target.sh @@ -6,7 +6,11 @@ # Stages, stopping as soon as the target fits under the cap: # 1. delete every `incremental/` cache (rebuilt on the next build) # 2. delete the host `debug/` profile (cargo check/test, rust-analyzer) -# 3. delete the whole target (next build is a full rebuild) +# 3. delete the target's contents (next build is a full rebuild) +# +# Safety: only acts on a directory Cargo created (named `target`, holding +# Cargo's CACHEDIR.TAG), never on `/` or $HOME, and does nothing while any +# cargo/rustc process is running, since worktrees share this target. # # Usage: prune-rust-target.sh # Env: LITTER_RUST_TARGET_MAX_GB (default 60; 0 disables pruning) @@ -15,9 +19,28 @@ set -euo pipefail target="${1:?usage: prune-rust-target.sh }" max_gb="${LITTER_RUST_TARGET_MAX_GB:-60}" -[ "$max_gb" = "0" ] && exit 0 +case "$max_gb" in + '' | *[!0-9]*) + echo "==> prune-rust-target: LITTER_RUST_TARGET_MAX_GB must be a whole number, got '$max_gb'; not pruning" >&2 + exit 0 + ;; +esac +[ "$max_gb" -eq 0 ] && exit 0 [ -d "$target" ] || exit 0 +target="$(cd "$target" && pwd -P)" +if [ "$target" = "/" ] || [ "$target" = "$(cd "$HOME" && pwd -P)" ] \ + || [ "$(basename "$target")" != "target" ] \ + || ! grep -qs "Signature: 8a477f597d28d172789f06886806bc55" "$target/CACHEDIR.TAG"; then + echo "==> prune-rust-target: $target is not a Cargo target directory; not pruning" >&2 + exit 0 +fi + +if pgrep -x cargo >/dev/null 2>&1 || pgrep -x rustc >/dev/null 2>&1; then + echo "==> prune-rust-target: a cargo/rustc process is running; skipping this time" >&2 + exit 0 +fi + size_gb() { du -sk "$target" 2>/dev/null | awk '{print int($1 / 1048576)}'; } size="$(size_gb)" @@ -34,5 +57,5 @@ size="$(size_gb)" echo " after dropping host debug profile: ${size}GB" [ "$size" -le "$max_gb" ] && exit 0 -echo " still over cap; removing the whole target (next build is a full rebuild)" -rm -rf "$target" +echo " still over cap; clearing the target (next build is a full rebuild)" +find "$target" -mindepth 1 -maxdepth 1 ! -name CACHEDIR.TAG -exec rm -rf {} +