diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 0c5f250128f..5355837f5e3 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -173,6 +173,7 @@ export default defineConfig({ { label: "Disk Usage from Temp Files", translations: { fr: "Espace disque et fichiers temporaires", ko: "임시 파일 디스크 사용량", "zh-CN": "临时文件磁盘占用", "zh-TW": "暫存檔磁碟用量", ru: "Использование диска временными файлами", ja: "一時ファイルのディスク使用量", tr: "Geçici Dosya Disk Kullanımı" }, slug: "troubleshooting/disk-usage-temp-files" }, { label: "Codex Cannot Sign In or Load", translations: { fr: "Codex ne peut pas se connecter", ko: "Codex 로그인 불가", "zh-CN": "Codex 无法登录", "zh-TW": "Codex 無法登入", ru: "Codex не может войти", ja: "Codex にサインインできない", tr: "Codex Oturum Açamıyor" }, slug: "troubleshooting/codex-cannot-sign-in" }, { label: "Update Failed on Windows", translations: { fr: "Échec de la mise à jour sous Windows", ko: "Windows에서 업데이트 실패", "zh-CN": "Windows 上更新失败", "zh-TW": "Windows 上更新失敗", ru: "Сбой обновления в Windows", ja: "Windows で更新に失敗する", tr: "Windows'ta Güncelleme Başarısız" }, slug: "troubleshooting/update-failed" }, + { label: "Spend Ledger Refused in a Synced Folder", translations: { fr: "Registre de dépenses refusé dans un dossier synchronisé", ko: "동기화 폴더에서 지출 원장 거부", "zh-CN": "同步文件夹中的支出账本被拒绝", "zh-TW": "同步資料夾中的支出帳本被拒絕", ru: "Отказ журнала расходов в синхронизируемой папке", ja: "同期フォルダーで支出台帳が拒否される", tr: "Eşitlenen Klasörde Harcama Defteri Reddi" }, slug: "troubleshooting/spend-ledger-synced-folder" }, ], }, { label: "Contributing", translations: { fr: "Contribuer", ko: "기여하기", "zh-CN": "贡献", "zh-TW": "貢獻", ru: "Как внести вклад", ja: "コントリビュート", tr: "Katkıda Bulunma" }, slug: "contributing" }, diff --git a/docs-site/src/content/docs/troubleshooting/spend-ledger-synced-folder.md b/docs-site/src/content/docs/troubleshooting/spend-ledger-synced-folder.md new file mode 100644 index 00000000000..d9bc77574fc --- /dev/null +++ b/docs-site/src/content/docs/troubleshooting/spend-ledger-synced-folder.md @@ -0,0 +1,68 @@ +--- +title: Spend Ledger Refused in a Synced Folder +description: Why requests can fail with "Spend-ledger storage could not be opened safely" when the opencodex state directory is inside iCloud Drive or another synced folder, and how to fix it. +--- + +Some macOS users saw requests fail intermittently with HTTP 502 and this message, while +other requests in the same session succeeded: + +```text +Provider unreachable: Spend-ledger storage could not be opened safely. +``` + +Current builds say which file and which check refused it, for example: + +```text +Spend-ledger storage could not be opened safely (journal: extra-hard-link). +``` + +## What the check is + +opencodex keeps a spend ledger in its state directory (`~/.opencodex` by default, or +`OPENCODEX_HOME`): a journal file, `spend-ledger.jsonl`, and a salt file, `spend-ledger.salt`. +Before every write it checks that each file is a regular file owned by you, is not a symbolic +link, and has exactly one directory entry. A second hard link would mean another name elsewhere +on the volume can see or change the same bytes, so opencodex refuses instead of writing through +it. This check stays strict on purpose. + +| Condition in the message | Meaning | +| --- | --- | +| `extra-hard-link` | Another directory entry points at the same file. | +| `symbolic-link` | The ledger file is a symbolic link. | +| `not-regular-file` | Something other than a regular file sits at the ledger path. | +| `foreign-owner` | The file belongs to a different user. | +| `invalid-salt` | The salt file exists but its content is not a valid salt. | + +The role in the message is `journal`, `journal-compaction` (the temporary file written while +the journal is compacted) or `salt`. + +## Why a synced folder triggers it + +macOS sync services, including iCloud Drive with "Desktop & Documents Folders" turned on and +File Provider clients such as OneDrive, Dropbox and Google Drive, can briefly keep a second link +to a file while they stage or upload a change. If the state directory is inside such a folder, +the journal can have two links for a moment after an ordinary write. A request that lands in +that moment is refused, and the next one may succeed. Once the sync settles, the file is back to +one link, so inspecting it afterwards shows nothing wrong. + +At startup opencodex now warns when the state directory resolves inside iCloud Drive +(`~/Library/Mobile Documents`), a File Provider folder (`~/Library/CloudStorage`), or Desktop +or Documents while iCloud Desktop & Documents sync appears to be on. The warning is advisory. +The detection reads the folder layout and can be wrong in either direction. + +## Fix + +Keep the state directory outside synced folders. The default `~/.opencodex` is not synced. + +1. Stop opencodex. +2. Move or copy the state directory to an unsynced location, for example `~/.opencodex-trial`. +3. Set `OPENCODEX_HOME` to that location, or unset it to use the default, and start opencodex + again. + +Do not delete the journal, relax its permissions, or remove the check to make the error go away. +The journal holds your recorded spend, and the check is what keeps it from being written through +an unexpected link. + +If the message names a condition other than `extra-hard-link`, or the state directory is not in +a synced folder, please open an issue with the full refusal message. It contains no path, +account or request content. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 23a8b1f86fc..f15fd86ad2d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1688,6 +1688,7 @@ "injection-model-suggest-routes.test.ts": "codex-integration", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations.test.ts": "clients", + "synced-state-location.test.ts": "lib", "synthetic-tool.test.ts": "images", "system-env.test.ts": "server", "system-restart-client-package-tree.test.ts": "cli", diff --git a/src/lib/spend-reservation-ledger.ts b/src/lib/spend-reservation-ledger.ts index 1a7ff2b4c64..0350fb40757 100644 --- a/src/lib/spend-reservation-ledger.ts +++ b/src/lib/spend-reservation-ledger.ts @@ -442,17 +442,54 @@ function ledgerEntryExists(path: string): boolean { } } -function assertSafeLedgerFile(path: string): void { - const stat = lstatSync(path); - if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 - || (process.platform !== "win32" && stat.uid !== process.getuid!())) { - throw new SpendLedgerOwnerError( +/** Which ledger file a refusal is about. A role, never a path: the path names the user's home. */ +export type SpendLedgerFileRole = "journal" | "journal-compaction" | "salt"; + +/** The one condition that refused the file, so a report can say which guard fired. */ +export type SpendLedgerFileRefusal = + | "not-regular-file" + | "symbolic-link" + | "extra-hard-link" + | "foreign-owner" + | "invalid-salt"; + +/** + * A ledger file failed a safety condition. + * + * The guard is unchanged; this only says which file role and which condition refused it. Issue + * #6314 sat for days on "could not be opened safely" because the same sentence covered five + * conditions and two files, and the one that fired (a second hard link to the journal, most + * likely held briefly by a cloud-sync daemon) could only be found with an instrumented build. + * Role and condition are fixed vocabulary, so the message carries no path, salt, alias or + * request content. + */ +export class SpendLedgerFileRefusedError extends SpendLedgerOwnerError { + constructor(readonly role: SpendLedgerFileRole, readonly refusal: SpendLedgerFileRefusal) { + super( "SPEND_LEDGER_OWNER_UNAVAILABLE", - "Spend-ledger storage could not be opened safely.", + `Spend-ledger storage could not be opened safely (${role}: ${refusal}).` + + (refusal === "extra-hard-link" + ? " Another directory entry links to this file; if the opencodex state directory is inside an iCloud Drive or other synced folder, move it out." + : ""), ); + this.name = "SpendLedgerFileRefusedError"; } } +function ledgerFileRefusal(path: string): SpendLedgerFileRefusal | undefined { + const stat = lstatSync(path); + if (stat.isSymbolicLink()) return "symbolic-link"; + if (!stat.isFile()) return "not-regular-file"; + if (stat.nlink !== 1) return "extra-hard-link"; + if (process.platform !== "win32" && stat.uid !== process.getuid!()) return "foreign-owner"; + return undefined; +} + +function assertSafeLedgerFile(path: string, role: SpendLedgerFileRole): void { + const refusal = ledgerFileRefusal(path); + if (refusal !== undefined) throw new SpendLedgerFileRefusedError(role, refusal); +} + /** * The production journal. Its location comes from the owned state directory and every touch * proves that ownership, so there is no entrypoint here that writes a caller-chosen path. @@ -471,7 +508,7 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ read(): string[] { assertStorageOwned(storage); if (!ledgerEntryExists(path)) return []; - assertSafeLedgerFile(path); + assertSafeLedgerFile(path, "journal"); // Replay is once per process and is the moment a journal inherited from an older build // or a restored backup first passes through here. hardenLedgerFile(path, { force: true }); @@ -481,9 +518,9 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ assertStorageOwned(storage); ensureDir(); const created = !ledgerEntryExists(path); - if (!created) assertSafeLedgerFile(path); + if (!created) assertSafeLedgerFile(path, "journal"); appendFileSync(path, line + "\n", { encoding: "utf8", mode: 0o600 }); - assertSafeLedgerFile(path); + assertSafeLedgerFile(path, "journal"); hardenLedgerFile(path, { force: created }); }, rewrite(lines: string[]): void { @@ -491,7 +528,7 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ ensureDir(); // Same directory, so the rename is atomic on the same filesystem: a crash mid-compaction // leaves either the old journal or the new one, never a half-written ledger. - if (ledgerEntryExists(path)) assertSafeLedgerFile(path); + if (ledgerEntryExists(path)) assertSafeLedgerFile(path, "journal"); const temp = `${path}.compact-${process.pid}-${randomBytes(6).toString("hex")}`; // The creation is INSIDE the cleanup, not before it. The name carries random bytes, so a // failure anywhere after the entry exists used to leave a uniquely named file and the next @@ -514,7 +551,7 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ closeSync(fd); fd = undefined; journalFaultForTests?.("validate", temp); - assertSafeLedgerFile(temp); + assertSafeLedgerFile(temp, "journal-compaction"); journalFaultForTests?.("harden", temp); hardenLedgerFile(temp, { force: true }); journalFaultForTests?.("rename", temp); @@ -528,7 +565,7 @@ export function createOwnedFileSpendJournal(storage: SpendLedgerStorage): SpendJ try { unlinkSync(temp); } catch { /* same */ } } } - assertSafeLedgerFile(path); + assertSafeLedgerFile(path, "journal"); hardenLedgerFile(path, { force: true }); }, }; @@ -545,14 +582,11 @@ export function loadOrCreateSpendLedgerSalt(storage: SpendLedgerStorage): string assertStorageOwned(storage); const path = spendLedgerStoragePath(storage); if (ledgerEntryExists(path)) { - assertSafeLedgerFile(path); + assertSafeLedgerFile(path, "salt"); hardenLedgerFile(path, { force: true }); const existing = readFileSync(path, "utf8").trim(); if (/^[0-9a-f]{32,}$/.test(existing)) return existing; - throw new SpendLedgerOwnerError( - "SPEND_LEDGER_OWNER_UNAVAILABLE", - "Spend-ledger storage could not be opened safely.", - ); + throw new SpendLedgerFileRefusedError("salt", "invalid-salt"); } const dir = dirname(path); assertStorageOwned(storage); @@ -560,7 +594,7 @@ export function loadOrCreateSpendLedgerSalt(storage: SpendLedgerStorage): string mkdirSync(dir, { recursive: true, mode: 0o700 }); const salt = randomBytes(32).toString("hex"); writeFileSync(path, salt + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); - assertSafeLedgerFile(path); + assertSafeLedgerFile(path, "salt"); hardenLedgerFile(path, { force: true }); return salt; } @@ -1205,8 +1239,8 @@ export function sharedSpendLedger(): SpendReservationLedger { const saltPath = spendLedgerStoragePath(saltStorage); const assertOwnedAccounting = (): void => { assertStorageOwned(journalStorage); - if (ledgerEntryExists(journalPath)) assertSafeLedgerFile(journalPath); - if (ledgerEntryExists(saltPath)) assertSafeLedgerFile(saltPath); + if (ledgerEntryExists(journalPath)) assertSafeLedgerFile(journalPath, "journal"); + if (ledgerEntryExists(saltPath)) assertSafeLedgerFile(saltPath, "salt"); }; sharedLedger = createSpendReservationLedger({ journal: createOwnedFileSpendJournal(journalStorage), diff --git a/src/lib/synced-state-location.ts b/src/lib/synced-state-location.ts new file mode 100644 index 00000000000..21b4bae24fb --- /dev/null +++ b/src/lib/synced-state-location.ts @@ -0,0 +1,91 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +/** + * Where a state directory sits relative to the folders macOS keeps in sync with a cloud. + * + * A sync daemon can hold a second hard link to a file while it stages or uploads a change. The + * spend ledger refuses a journal with a second link on purpose, so a state directory inside a + * synced folder turns ordinary requests into intermittent 502s (#6314). This only answers + * "is it likely synced"; the guard itself stays strict. + */ +export type SyncedStateLocation = "icloud-drive" | "file-provider" | "icloud-desktop-documents"; + +export interface SyncedStateLocationProbe { + readonly platform?: NodeJS.Platform; + readonly home?: string; + /** Resolve symlinks. Throws when the path does not exist yet. */ + readonly realpath?: (path: string) => string; + /** Whether a directory entry exists at the path, without following it. */ + readonly entryExists?: (path: string) => boolean; +} + +const defaultEntryExists = (path: string): boolean => { + try { lstatSync(path); return true; } catch { return false; } +}; + +/** + * Advisory only. Apple publishes no API a CLI can ask, so this reads the observed layout: + * iCloud Drive lives under ~/Library/Mobile Documents, File Provider clients (OneDrive, Dropbox, + * Google Drive) under ~/Library/CloudStorage, and with "Desktop & Documents Folders" turned on + * iCloud Drive holds a Desktop/Documents entry of its own. A false positive costs one warning + * line; nothing is refused on the strength of it. + */ +export function syncedStateLocation(dir: string, probe: SyncedStateLocationProbe = {}): SyncedStateLocation | undefined { + if ((probe.platform ?? process.platform) !== "darwin") return undefined; + const realpath = probe.realpath ?? ((path: string) => realpathSync.native(path)); + const entryExists = probe.entryExists ?? defaultEntryExists; + const canonical = (path: string): string => { + try { return realpath(path); } catch { return resolve(path); } + }; + // The default APFS volume is case-insensitive, so ~/documents and ~/Documents are one folder. + const fold = (path: string): string => path.toLowerCase(); + const home = canonical(probe.home ?? homedir()); + const target = fold(canonical(dir)); + const within = (root: string): boolean => { + const folded = fold(root); + return target === folded || target.startsWith(folded + "/"); + }; + const mobileDocuments = join(home, "Library", "Mobile Documents"); + if (within(mobileDocuments)) return "icloud-drive"; + if (within(join(home, "Library", "CloudStorage"))) return "file-provider"; + for (const folder of ["Desktop", "Documents"]) { + if (within(join(home, folder)) && entryExists(join(mobileDocuments, "com~apple~CloudDocs", folder))) { + return "icloud-desktop-documents"; + } + } + return undefined; +} + +const LOCATION_LABEL: Record = { + "icloud-drive": "inside iCloud Drive", + "file-provider": "inside a cloud-storage (File Provider) folder", + "icloud-desktop-documents": "in Desktop or Documents, which iCloud Drive appears to sync", +}; + +/** The startup warning. Names the location kind, never the path. */ +export function syncedStateWarning(location: SyncedStateLocation): string[] { + return [ + `⚠️ The opencodex state directory (OPENCODEX_HOME) is ${LOCATION_LABEL[location]}.`, + " A sync service can briefly add a second link to the spend ledger, which opencodex", + " refuses, so requests may fail intermittently. Set OPENCODEX_HOME to a folder outside", + " synced locations (the default ~/.opencodex is not synced).", + ]; +} + +let warned = false; + +/** Warn once per process when the state directory looks synced. Never throws. */ +export function warnIfSyncedStateDirectory(dir: string, warn: (line: string) => void = console.warn): void { + if (warned) return; + let location: SyncedStateLocation | undefined; + try { location = syncedStateLocation(dir); } catch { return; } + if (location === undefined) return; + warned = true; + // Runs while the startup owner lease is held and before its rollback is registered, so a + // throwing sink must not escape and strand the lease. + try { + for (const line of syncedStateWarning(location)) warn(line); + } catch { /* advisory output only */ } +} diff --git a/src/server/index/spend-ledger-lifecycle.ts b/src/server/index/spend-ledger-lifecycle.ts index ddcc70a6a3b..81f07b44d76 100644 --- a/src/server/index/spend-ledger-lifecycle.ts +++ b/src/server/index/spend-ledger-lifecycle.ts @@ -7,6 +7,7 @@ import { configureSharedSpendLedger, spendPolicyFromConfig, } from "../../lib/spend-reservation-ledger"; +import { warnIfSyncedStateDirectory } from "../../lib/synced-state-location"; const failedStartRollbacks = new WeakMap>(); @@ -34,6 +35,9 @@ export interface SpendLedgerServerLifecycle { /** Acquire before config loading so every later startup failure has one rollback owner. */ export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedgerServerLifecycle { const owner: SpendLedgerOwnerLease = acquireSpendLedgerOwner(configDir); + // Advisory: a synced state directory makes the journal's hard-link guard refuse intermittently + // (#6314). Said once at startup instead of being discovered from a 502. + warnIfSyncedStateDirectory(configDir); // Each entry returns whatever the listener's own stop returned. Typed as void-or-promise // because the rollback below has to WAIT on it: declaring it `() => void` let the call site // compile while statically erasing the promise it needs to await. diff --git a/structure/transports/responses-spend.md b/structure/transports/responses-spend.md index 6bfc03a8938..bd69a6f0b46 100644 --- a/structure/transports/responses-spend.md +++ b/structure/transports/responses-spend.md @@ -148,6 +148,20 @@ directory entry that is a link -- including one whose target does not exist -- i of followed. A separate process may use a separate directory. SQLite crash release permits the next owner without stale-PID or TTL reclamation. +Every file check admits a ledger file only when it is a regular file, not a link, with exactly +one directory entry, owned by the process user. A refusal raises `SpendLedgerFileRefusedError`, a +`SPEND_LEDGER_OWNER_UNAVAILABLE` owner error. The error carries the file role (`journal`, +`journal-compaction`, `salt`) and the failed condition (`not-regular-file`, `symbolic-link`, +`extra-hard-link`, `foreign-owner`, `invalid-salt`), and never the path, salt, alias or request +content (#6314). Before this, one sentence covered five conditions and two files. A macOS sync +daemon briefly holding a second link to a journal inside a synced folder could then only be +diagnosed from an instrumented build. The guard is unchanged. +`src/lib/synced-state-location.ts` is the advisory half. `acquireSpendLedgerServerLifecycle` +warns once at startup when the state directory resolves inside iCloud Drive, a File Provider +folder, or Desktop/Documents with iCloud Desktop & Documents sync detected, and it refuses +nothing on that basis. `tests/lib/spend-ledger-file-journal.test.ts` pins the refusal shape, and +`tests/lib/synced-state-location.test.ts` pins the classification. + The journal survives an ordinary process restart once its writes reached the filesystem. It does not claim host power-loss durability: the append path does not fsync each record, so power loss can drop recently acknowledged filesystem writes. A torn final line remains the only replay corruption diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 7a2c8445066..f0d3cdb0c2e 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1702,6 +1702,7 @@ "injection-model-suggest-routes.test.ts": "codex-integration", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations.test.ts": "clients", + "synced-state-location.test.ts": "lib", "synthetic-tool.test.ts": "images", "system-env.test.ts": "server", "system-restart-client-package-tree.test.ts": "cli", diff --git a/tests/lib/spend-ledger-file-journal.test.ts b/tests/lib/spend-ledger-file-journal.test.ts index ea45233c3a0..d2b3e2819c4 100644 --- a/tests/lib/spend-ledger-file-journal.test.ts +++ b/tests/lib/spend-ledger-file-journal.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { appendFileSync, chmodSync, existsSync, mkdtempSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, existsSync, linkSync, mkdtempSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { @@ -7,6 +7,7 @@ import { loadOrCreateSpendLedgerSalt, SPEND_LEDGER_JOURNAL_FILENAME, SPEND_LEDGER_SALT_FILENAME, + SpendLedgerFileRefusedError, resetSharedSpendLedgerForTest, setSpendJournalFaultForTests, } from "../../src/lib/spend-reservation-ledger"; @@ -252,4 +253,47 @@ describe("spend ledger file journal", () => { expect(loadOrCreateSpendLedgerSalt(mintSpendLedgerStorage(SPEND_LEDGER_SALT_FILENAME))).toBe(minted); if (posixModes) expect(modeOf(path)).toBe(0o600); }); + + // #6314: a sync daemon held a second hard link to the journal for a moment, and the refusal + // said only "could not be opened safely". The guard stays strict; the refusal now names the + // file role and the condition, and nothing else. + test.skipIf(process.platform === "win32")("a second hard link is refused by role and condition, without a path", () => { + const dir = ownedHome("ocx-spend-journal-link-"); + const journalPath = join(dir, SPEND_LEDGER_JOURNAL_FILENAME); + const alias = join(dir, "sync-staging-alias"); + const journal = createOwnedFileSpendJournal(mintSpendLedgerStorage(SPEND_LEDGER_JOURNAL_FILENAME)); + journal.append(line("alias-one")); + const original = readFileSync(journalPath, "utf8"); + linkSync(journalPath, alias); + + let refused: unknown; + try { journal.append(line("alias-two")); } catch (error) { refused = error; } + expect(refused).toBeInstanceOf(SpendLedgerFileRefusedError); + expect(refused).toBeInstanceOf(SpendLedgerOwnerError); + const error = refused as SpendLedgerFileRefusedError; + expect(error.code).toBe("SPEND_LEDGER_OWNER_UNAVAILABLE"); + expect(error.role).toBe("journal"); + expect(error.refusal).toBe("extra-hard-link"); + expect(error.message).toContain("(journal: extra-hard-link)"); + expect(error.message).not.toContain(dir); + expect(error.message).not.toContain(SPEND_LEDGER_JOURNAL_FILENAME); + expect(() => journal.rewrite?.call(journal, [line("checkpoint")])).toThrow(/\(journal: extra-hard-link\)/); + expect(readFileSync(journalPath, "utf8")).toBe(original); + + // Removing only the extra entry lets the same journal proceed: the refusal never damaged it. + unlinkSync(alias); + journal.append(line("alias-two")); + expect(journal.read()).toEqual([line("alias-one"), line("alias-two")]); + }); + + test("a salt with invalid content is refused as invalid-salt", () => { + const dir = ownedHome("ocx-spend-salt-invalid-"); + writeFileSync(join(dir, SPEND_LEDGER_SALT_FILENAME), "not-a-salt\n", { mode: 0o600 }); + let refused: unknown; + try { loadOrCreateSpendLedgerSalt(mintSpendLedgerStorage(SPEND_LEDGER_SALT_FILENAME)); } catch (error) { refused = error; } + expect(refused).toBeInstanceOf(SpendLedgerFileRefusedError); + expect((refused as SpendLedgerFileRefusedError).role).toBe("salt"); + expect((refused as SpendLedgerFileRefusedError).refusal).toBe("invalid-salt"); + expect((refused as Error).message).not.toContain(dir); + }); }); diff --git a/tests/lib/synced-state-location.test.ts b/tests/lib/synced-state-location.test.ts new file mode 100644 index 00000000000..6811249ef0d --- /dev/null +++ b/tests/lib/synced-state-location.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { + syncedStateLocation, + syncedStateWarning, + type SyncedStateLocationProbe, +} from "../../src/lib/synced-state-location"; + +const HOME = "/Users/example"; +const probe = (overrides: Partial = {}): SyncedStateLocationProbe => ({ + platform: "darwin", + home: HOME, + realpath: (path) => path, + entryExists: () => false, + ...overrides, +}); +const desktopDocumentsSynced = (path: string): boolean => + path === `${HOME}/Library/Mobile Documents/com~apple~CloudDocs/Documents` + || path === `${HOME}/Library/Mobile Documents/com~apple~CloudDocs/Desktop`; + +describe("synced state directory detection (#6314)", () => { + test("the default state directory is not synced", () => { + expect(syncedStateLocation(`${HOME}/.opencodex`, probe({ entryExists: desktopDocumentsSynced }))).toBeUndefined(); + }); + + test("iCloud Drive and File Provider folders are recognized", () => { + expect(syncedStateLocation(`${HOME}/Library/Mobile Documents/com~apple~CloudDocs/ocx`, probe())).toBe("icloud-drive"); + expect(syncedStateLocation(`${HOME}/Library/CloudStorage/Dropbox/ocx`, probe())).toBe("file-provider"); + }); + + test("Documents counts only when iCloud Desktop & Documents sync appears to be on", () => { + const dir = `${HOME}/Documents/ocx-trial/state`; + expect(syncedStateLocation(dir, probe())).toBeUndefined(); + expect(syncedStateLocation(dir, probe({ entryExists: desktopDocumentsSynced }))).toBe("icloud-desktop-documents"); + expect(syncedStateLocation(`${HOME}/Desktop/state`, probe({ entryExists: desktopDocumentsSynced }))).toBe("icloud-desktop-documents"); + }); + + test("the resolved path decides, so a symlink into iCloud Drive is caught", () => { + const realpath = (path: string): string => path === `${HOME}/state` ? `${HOME}/Library/Mobile Documents/com~apple~CloudDocs/state` : path; + expect(syncedStateLocation(`${HOME}/state`, probe({ realpath }))).toBe("icloud-drive"); + }); + + test("case differences do not hide a synced folder", () => { + expect(syncedStateLocation(`${HOME}/library/mobile documents/x`, probe())).toBe("icloud-drive"); + }); + + test("a sibling with a shared prefix is not inside the folder", () => { + expect(syncedStateLocation(`${HOME}/Documents-local/state`, probe({ entryExists: desktopDocumentsSynced }))).toBeUndefined(); + }); + + test("a directory that does not exist yet is still classified", () => { + const realpath = (): string => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); }; + expect(syncedStateLocation(`${HOME}/Library/CloudStorage/OneDrive/ocx`, probe({ realpath }))).toBe("file-provider"); + }); + + test("other platforms are never classified", () => { + expect(syncedStateLocation(`${HOME}/Library/Mobile Documents/x`, probe({ platform: "linux" }))).toBeUndefined(); + expect(syncedStateLocation(`${HOME}/Library/Mobile Documents/x`, probe({ platform: "win32" }))).toBeUndefined(); + }); + + test("the warning names the location kind and never a path", () => { + for (const location of ["icloud-drive", "file-provider", "icloud-desktop-documents"] as const) { + const text = syncedStateWarning(location).join("\n"); + expect(text).toContain("OPENCODEX_HOME"); + expect(text).not.toContain("/Users/"); + } + }); +});