Repository navigation
fix(spend): name the refused ledger file and condition, warn on synced state directories #6398
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Canonicalize each detection root before comparing paths. If Apply Proposed fix- const folded = fold(root);
+ const folded = fold(canonical(root));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| 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<SyncedStateLocation, string> = { | ||||||
| "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 */ } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Comment on lines
+151
to
+152
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Qualify the filesystem ownership requirement by platform. Line 152 states that every admitted file belongs to the process user. However, State that the process-user ownership requirement applies on non-Windows platforms. Keep the regular-file and link-count requirements platform-independent. As per coding guidelines, a structure document states “the contract that holds right now.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| `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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Qualify the sync-provider cause as unconfirmed.
This section presents transient hard links from sync services as the explanation for the intermittent refusals. The PR objectives state that the reported incident’s root cause is not confirmed. Describe this as a possible mechanism, so users do not treat the advisory location match as proof of the cause.
Proposed wording
As per path instructions, “Check that user-facing docs stay in sync with actual CLI/API behavior.” The PR objectives state that the incident’s root cause is not confirmed.
🤖 Prompt for AI Agents
Source: Path instructions