flowstatemcp 2.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. flowstate/__init__.py +34 -0
  2. flowstate/__main__.py +7 -0
  3. flowstate/activity.py +224 -0
  4. flowstate/agent_guide.py +251 -0
  5. flowstate/app_keys.py +141 -0
  6. flowstate/approvals.py +974 -0
  7. flowstate/approve_page.py +565 -0
  8. flowstate/ask.py +107 -0
  9. flowstate/autosession.py +480 -0
  10. flowstate/backup.py +201 -0
  11. flowstate/brief.py +420 -0
  12. flowstate/card_offer.py +208 -0
  13. flowstate/card_taps.py +165 -0
  14. flowstate/card_trust.py +127 -0
  15. flowstate/claims.py +461 -0
  16. flowstate/cleanup.py +619 -0
  17. flowstate/cli.py +728 -0
  18. flowstate/cli_data.py +434 -0
  19. flowstate/cli_more.py +302 -0
  20. flowstate/cli_review.py +459 -0
  21. flowstate/cloudflared_check.py +49 -0
  22. flowstate/conflict_card_ui.py +286 -0
  23. flowstate/conflict_cards.py +1193 -0
  24. flowstate/conflict_reopen.py +320 -0
  25. flowstate/conflicts.py +420 -0
  26. flowstate/connections.py +33 -0
  27. flowstate/database.py +2191 -0
  28. flowstate/dbconn.py +308 -0
  29. flowstate/deletes.py +860 -0
  30. flowstate/embeddings.py +354 -0
  31. flowstate/erase.py +1047 -0
  32. flowstate/extras.py +71 -0
  33. flowstate/git_capture.py +157 -0
  34. flowstate/guard.py +224 -0
  35. flowstate/handoffs.py +168 -0
  36. flowstate/history.py +755 -0
  37. flowstate/history_page.py +276 -0
  38. flowstate/http_app.py +204 -0
  39. flowstate/identity.py +387 -0
  40. flowstate/install_name.py +140 -0
  41. flowstate/ledger.py +272 -0
  42. flowstate/legacy_args.py +65 -0
  43. flowstate/links.py +609 -0
  44. flowstate/markdown_io.py +242 -0
  45. flowstate/meaning.py +398 -0
  46. flowstate/memory_import.py +92 -0
  47. flowstate/migrations/001_auto_sessions.sql +16 -0
  48. flowstate/migrations/002_review_queue.sql +33 -0
  49. flowstate/migrations/003_links.sql +47 -0
  50. flowstate/migrations/004_review_mode.sql +24 -0
  51. flowstate/migrations/005_history.sql +21 -0
  52. flowstate/migrations/006_activity.sql +26 -0
  53. flowstate/migrations/007_meaning_index.sql +16 -0
  54. flowstate/migrations/008_git_capture.sql +12 -0
  55. flowstate/migrations/009_hard_rules.sql +13 -0
  56. flowstate/migrations/010_handoffs.sql +14 -0
  57. flowstate/migrations/011_record_meta.sql +13 -0
  58. flowstate/migrations/012_accountability.sql +71 -0
  59. flowstate/migrations/013_todo_claims.sql +36 -0
  60. flowstate/migrations/014_decision_conflicts.sql +41 -0
  61. flowstate/migrations/015_session_runs.sql +17 -0
  62. flowstate/migrations/016_write_receipts.sql +35 -0
  63. flowstate/migrations/017_run_views.sql +26 -0
  64. flowstate/migrations/018_run_views_upto.sql +4 -0
  65. flowstate/migrations/019_approvals.sql +47 -0
  66. flowstate/migrations/020_source_stamps.sql +10 -0
  67. flowstate/migrations/021_review_links.sql +11 -0
  68. flowstate/migrations/022_review_start_tickets.sql +10 -0
  69. flowstate/migrations/023_conflict_cards.sql +68 -0
  70. flowstate/migrations/024_rule_confirmations.sql +7 -0
  71. flowstate/migrations/025_confirmed_hash.sql +7 -0
  72. flowstate/migrations/__init__.py +149 -0
  73. flowstate/models.py +307 -0
  74. flowstate/names.py +157 -0
  75. flowstate/offsite.py +386 -0
  76. flowstate/offsite_crypto.py +272 -0
  77. flowstate/paths.py +265 -0
  78. flowstate/popup.py +238 -0
  79. flowstate/priority.py +66 -0
  80. flowstate/privatefs.py +476 -0
  81. flowstate/project_history.py +710 -0
  82. flowstate/provenance.py +696 -0
  83. flowstate/receipts.py +377 -0
  84. flowstate/relay/__init__.py +72 -0
  85. flowstate/relay/acme.py +271 -0
  86. flowstate/relay/api.py +164 -0
  87. flowstate/relay/cli.py +344 -0
  88. flowstate/relay/crypto.py +137 -0
  89. flowstate/relay/ct.py +118 -0
  90. flowstate/relay/runner.py +177 -0
  91. flowstate/relay/store.py +191 -0
  92. flowstate/relay/tunnel.py +413 -0
  93. flowstate/release_signers +14 -0
  94. flowstate/remote_auth.py +1219 -0
  95. flowstate/review.py +320 -0
  96. flowstate/review_apply.py +140 -0
  97. flowstate/review_card_ui.py +241 -0
  98. flowstate/review_cards.py +564 -0
  99. flowstate/review_mode.py +274 -0
  100. flowstate/review_page.py +1361 -0
  101. flowstate/review_phone.py +465 -0
  102. flowstate/review_tunnel.py +338 -0
  103. flowstate/routing.py +392 -0
  104. flowstate/rules.py +418 -0
  105. flowstate/safety.py +98 -0
  106. flowstate/saves.py +83 -0
  107. flowstate/scheduler.py +160 -0
  108. flowstate/schema.sql +713 -0
  109. flowstate/search.py +355 -0
  110. flowstate/secret_store.py +271 -0
  111. flowstate/secrets_scan.py +135 -0
  112. flowstate/selfupdate.py +1020 -0
  113. flowstate/server.py +1299 -0
  114. flowstate/session_contents.py +382 -0
  115. flowstate/session_records.py +629 -0
  116. flowstate/site_style.py +46 -0
  117. flowstate/stored.py +158 -0
  118. flowstate/story.py +442 -0
  119. flowstate/surface.py +173 -0
  120. flowstate/tasks.py +231 -0
  121. flowstate/term.py +40 -0
  122. flowstate/tool_labels.py +104 -0
  123. flowstate/tooldefs.py +383 -0
  124. flowstate/tools/__init__.py +118 -0
  125. flowstate/tools/core.py +545 -0
  126. flowstate/tools/files.py +416 -0
  127. flowstate/tools/git_ops.py +1081 -0
  128. flowstate/tools/intelligence.py +376 -0
  129. flowstate/tools/learning.py +282 -0
  130. flowstate/tools/problems.py +159 -0
  131. flowstate/tools/sessions.py +266 -0
  132. flowstate/tools/story.py +48 -0
  133. flowstate/tools/todos.py +79 -0
  134. flowstate/tools/utils.py +71 -0
  135. flowstate/tools/variables.py +345 -0
  136. flowstate/updates.py +482 -0
  137. flowstate/urls.py +7 -0
  138. flowstate/waiting.py +828 -0
  139. flowstate/who.py +302 -0
  140. flowstatemcp-2.5.0.dist-info/METADATA +88 -0
  141. flowstatemcp-2.5.0.dist-info/RECORD +143 -0
  142. flowstatemcp-2.5.0.dist-info/WHEEL +4 -0
  143. flowstatemcp-2.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,272 @@
1
+ """Encryption for the off-site GitHub copy (2.4.4, data security audit D8).
2
+
3
+ The nightly copy is encrypted on this computer before it is pushed; GitHub only ever holds the
4
+ encrypted file. AES-256-GCM from the `cryptography` package (it comes with `mcp`; FlowState lists it as its
5
+ `offsite` extra and checks for it in tests; the relay uses it too). Without it the push is
6
+ refused: an unencrypted copy is never pushed.
7
+
8
+ The key: 32 random bytes made on this computer the first time, kept in the OS credential store
9
+ (Windows Credential Manager `flowstate:offsite-key`, macOS Keychain `flowstate-offsite-key`,
10
+ Linux a 0600 file `~/.config/flowstate/offsite-key`). The recovery code is the same key written
11
+ for people (`flowstate offsite key`): save it in a password manager. Without it, a lost or
12
+ replaced computer can't read the copy; that is the point.
13
+
14
+ File format (`.fsenc`): MAGIC, 8-byte key id (start of SHA-256 of the key), 8-byte random nonce
15
+ prefix, 4-byte chunk size, and (format 2, MAGIC2) the 8-byte time the copy was made (Unix
16
+ seconds); then 1 MiB chunks, each sealed with nonce = prefix + 4-byte counter and the header +
17
+ counter + a last-chunk flag as associated data, so chunks can't be reordered, dropped or cut off
18
+ without the check failing, and the time in the header can't be changed. A file with no chunk at
19
+ all (cut down to its header) is refused. Format-1 files (no time) are still read.
20
+
21
+ The key is made only when the store says definitely "there is none"; a store that can't be
22
+ read stops the push, and an existing key is never written over (re-audit N-F6).
23
+ """
24
+
25
+ import base64
26
+ import hashlib
27
+ import os
28
+ import secrets
29
+ import struct
30
+ from pathlib import Path
31
+ from typing import Optional
32
+
33
+ MAGIC = b"FSENC1\0"
34
+ MAGIC2 = b"FSENC2\0" # + the time the copy was made, sealed with the chunks
35
+ CHUNK = 1 << 20
36
+ _HEADER = len(MAGIC) + 8 + 8 + 4
37
+ _HEADER2 = _HEADER + 8
38
+ SUFFIX = ".fsenc"
39
+
40
+
41
+ class CryptoError(Exception):
42
+ pass
43
+
44
+
45
+ def _aes(key: bytes):
46
+ try:
47
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
48
+ except ImportError:
49
+ raise CryptoError("encrypting the off-site copy needs the `cryptography` package "
50
+ "(one of FlowState's own packages). Nothing was "
51
+ "pushed; reinstall FlowState's packages and try again.") from None
52
+ return AESGCM(key)
53
+
54
+
55
+ def key_id(key: bytes) -> str:
56
+ return hashlib.sha256(key).hexdigest()[:16]
57
+
58
+
59
+ # ---------------------------------------------------------------- the key
60
+ def _store():
61
+ from . import secret_store as SS
62
+ if os.environ.get("FLOWSTATE_TOKEN_STORE_FILE"): # tests and sandboxes: a private file
63
+ return SS.FileStore(Path(os.environ["FLOWSTATE_TOKEN_STORE_FILE"]).expanduser()
64
+ .with_name("offsite-key"))
65
+ import platform
66
+ system = platform.system()
67
+ if system == "Windows":
68
+ return SS.WindowsStore(target="flowstate:offsite-key")
69
+ if system == "Darwin":
70
+ return SS.MacStore(service="flowstate-offsite-key")
71
+ from . import paths
72
+ return SS.FileStore(paths.config_dir() / "offsite-key")
73
+
74
+
75
+ _DAMAGED = ("the off-site key stored on this computer is damaged, so FlowState can't use it. "
76
+ "It was not replaced (that would lose your earlier copies); nothing was {doing}. If "
77
+ "you saved its recovery code, you can still restore those copies with it.")
78
+
79
+
80
+ def _empty(store, doing: str = "pushed") -> str:
81
+ where = {"macOS Keychain": "the Keychain item flowstate-offsite-key (Keychain Access)",
82
+ "Windows Credential Manager": "the Credential Manager item flowstate:offsite-key"}
83
+ how = where.get(getattr(store, "name", ""), f"the file {getattr(store, 'path', 'offsite-key')}")
84
+ return (f"the off-site key on this computer is empty: {how} exists but holds no key, so "
85
+ f"FlowState can't use it. It was not replaced; nothing was {doing}. If you never "
86
+ f"pushed an encrypted off-site copy, delete that empty item and push again (a new "
87
+ f"key is made then). If you did, keep it and restore those copies with the "
88
+ f"recovery code you saved (task 56 Low).")
89
+
90
+
91
+ def _decode(text: str, doing: str = "pushed") -> bytes:
92
+ try:
93
+ t = text.strip()
94
+ raw = base64.urlsafe_b64decode(t + "=" * (-len(t) % 4))
95
+ except (ValueError, TypeError):
96
+ raise CryptoError(_DAMAGED.format(doing=doing)) from None
97
+ if len(raw) != 32:
98
+ raise CryptoError(_DAMAGED.format(doing=doing))
99
+ return raw
100
+
101
+
102
+ def get_key(store=None, doing: str = "pushed") -> Optional[bytes]:
103
+ """The key, or None only when the store says there is none. A store that can't be read
104
+ raises CryptoError (never "no key"). `doing`: what didn't happen, for its message
105
+ ("pushed"; "restored" for a restore, "set up" for offsite setup: task 56 Info)."""
106
+ store = store or _store()
107
+ try:
108
+ v = store.get()
109
+ except Exception as e: # noqa: BLE001 - locked Keychain, denied prompt, unreadable file
110
+ raise CryptoError(
111
+ f"couldn't read the off-site key from {getattr(store, 'name', 'the credential store')} "
112
+ f"({e if str(e) else type(e).__name__}). Nothing was {doing} and no new key was "
113
+ f"made; unlock it (or allow FlowState) and try again.") from None
114
+ if v is None:
115
+ return None
116
+ if not str(v).strip(): # the item is there but empty: not "no key", never replaced
117
+ raise CryptoError(_empty(store, doing))
118
+ return _decode(v, doing)
119
+
120
+
121
+ def get_or_create_key(store=None) -> tuple:
122
+ """(key, made_now). A new key only after a definite "not found", and only with an
123
+ add-only write: an existing item is never overwritten."""
124
+ store = store or _store()
125
+ key = get_key(store)
126
+ if key is not None:
127
+ return key, False
128
+ # one first-time key at a time on this computer: Credential Manager has no add-only write,
129
+ # so two pushes at the same moment could each make one, and the first copy's key be lost
130
+ from . import paths
131
+ from .privatefs import private_dir
132
+ private_dir(paths.config_dir())
133
+ try:
134
+ with paths._lock(paths.config_dir() / "offsite-key"):
135
+ return _create_key(store)
136
+ except TimeoutError:
137
+ raise CryptoError("another FlowState is making the off-site key right now; nothing was "
138
+ "pushed. Try again in a moment.") from None
139
+
140
+
141
+ def _create_key(store) -> tuple:
142
+ from . import secret_store as SS
143
+ key = get_key(store) # made by another FlowState while this one waited
144
+ if key is not None:
145
+ return key, False
146
+ key = secrets.token_bytes(32)
147
+ text = base64.urlsafe_b64encode(key).decode().rstrip("=")
148
+ try:
149
+ if hasattr(store, "create"):
150
+ store.create(text)
151
+ else: # a store without an add-only write: check right before writing
152
+ if store.get() is not None:
153
+ raise SS.AlreadyStored("an off-site key appeared meanwhile; it was not replaced")
154
+ store.set(text)
155
+ except SS.AlreadyStored:
156
+ raise CryptoError("an off-site key was already stored (made by another FlowState at "
157
+ "the same moment?); it was not replaced. Nothing was pushed; try "
158
+ "again.") from None
159
+ except CryptoError:
160
+ raise
161
+ except Exception as e: # noqa: BLE001
162
+ raise CryptoError(f"the credential store refused the new off-site key "
163
+ f"({type(e).__name__}); nothing was pushed") from None
164
+ if get_key(store) != key:
165
+ raise CryptoError("the credential store didn't keep the new off-site key; nothing was "
166
+ "pushed")
167
+ return key, True
168
+
169
+
170
+ def recovery_code(key: bytes) -> str:
171
+ """The key for people: 52 letters and digits in groups of four."""
172
+ b32 = base64.b32encode(key).decode().rstrip("=")
173
+ return "-".join(b32[i:i + 4] for i in range(0, len(b32), 4))
174
+
175
+
176
+ def key_from_code(code: str) -> bytes:
177
+ s = "".join(ch for ch in (code or "").upper() if ch.isalnum())
178
+ try:
179
+ key = base64.b32decode(s + "=" * (-len(s) % 8))
180
+ except ValueError:
181
+ raise CryptoError("that recovery code isn't valid (letters A-Z and digits 2-7)") from None
182
+ if len(key) != 32:
183
+ raise CryptoError("that recovery code is the wrong length")
184
+ return key
185
+
186
+
187
+ # ---------------------------------------------------------------- files
188
+ def _aad(header: bytes, n: int, last: bool) -> bytes:
189
+ return header + struct.pack(">I?", n, last)
190
+
191
+
192
+ def encrypt_file(src: Path, dst: Path, key: bytes, created: Optional[int] = None) -> None:
193
+ """`created`: when the copy was made (Unix seconds; default now), sealed in the header."""
194
+ import time
195
+ aes = _aes(key)
196
+ prefix = secrets.token_bytes(8)
197
+ when = int(time.time()) if created is None else int(created)
198
+ header = (MAGIC2 + bytes.fromhex(key_id(key)) + prefix + struct.pack(">I", CHUNK)
199
+ + struct.pack(">Q", when))
200
+ from .privatefs import private_file
201
+ private_file(dst)
202
+ with open(src, "rb") as f, open(dst, "wb") as out:
203
+ out.write(header)
204
+ n, chunk = 0, f.read(CHUNK)
205
+ while True:
206
+ nxt = f.read(CHUNK)
207
+ last = not nxt
208
+ out.write(aes.encrypt(prefix + struct.pack(">I", n), chunk, _aad(header, n, last)))
209
+ if last:
210
+ break
211
+ n, chunk = n + 1, nxt
212
+
213
+
214
+ def is_encrypted(path: Path) -> bool:
215
+ try:
216
+ with open(path, "rb") as f:
217
+ return f.read(len(MAGIC)) in (MAGIC, MAGIC2)
218
+ except OSError:
219
+ return False
220
+
221
+
222
+ def file_key_id(path: Path) -> str:
223
+ with open(path, "rb") as f:
224
+ head = f.read(_HEADER)
225
+ return head[len(MAGIC):len(MAGIC) + 8].hex()
226
+
227
+
228
+ def decrypt_file(src: Path, dst: Path, key: bytes) -> dict:
229
+ """Decrypt into `dst` (private). Returns {"created": Unix seconds the copy was made, from
230
+ the sealed header, or None for a format-1 file}; only trustworthy because it returned."""
231
+ from cryptography.exceptions import InvalidTag
232
+ aes = _aes(key)
233
+ with open(src, "rb") as f:
234
+ header = f.read(_HEADER)
235
+ magic = header[:len(MAGIC)]
236
+ if len(header) != _HEADER or magic not in (MAGIC, MAGIC2):
237
+ raise CryptoError("not a FlowState encrypted backup")
238
+ created = None
239
+ if magic == MAGIC2:
240
+ extra = f.read(8)
241
+ if len(extra) != 8:
242
+ raise CryptoError("this encrypted copy is cut short or damaged (its header is "
243
+ "incomplete); nothing was restored")
244
+ header += extra
245
+ created = struct.unpack(">Q", extra)[0]
246
+ if header[len(MAGIC):len(MAGIC) + 8].hex() != key_id(key):
247
+ raise CryptoError("this backup was encrypted with a different key (another "
248
+ "computer's, or one from before a key reset): use its recovery code")
249
+ prefix = header[len(MAGIC) + 8:len(MAGIC) + 16]
250
+ size = struct.unpack(">I", header[len(MAGIC) + 16:len(MAGIC) + 20])[0] + 16
251
+ from .privatefs import private_file
252
+ dst.unlink(missing_ok=True)
253
+ private_file(dst)
254
+ try:
255
+ with open(dst, "wb") as out:
256
+ n, chunk = 0, f.read(size)
257
+ if not chunk: # cut down to its header: not an empty database
258
+ raise CryptoError("this encrypted copy is cut short or damaged (it has no "
259
+ "data after its header); nothing was restored")
260
+ while chunk:
261
+ nxt = f.read(size)
262
+ try:
263
+ out.write(aes.decrypt(prefix + struct.pack(">I", n), chunk,
264
+ _aad(header, n, not nxt)))
265
+ except InvalidTag:
266
+ raise CryptoError("the encrypted backup is damaged or was changed "
267
+ "(its check failed); nothing was restored") from None
268
+ n, chunk = n + 1, nxt
269
+ except Exception:
270
+ dst.unlink(missing_ok=True)
271
+ raise
272
+ return {"created": created}
flowstate/paths.py ADDED
@@ -0,0 +1,265 @@
1
+ """Per-OS folders FlowState uses besides the database itself. Standard library only.
2
+
3
+ backups: FLOWSTATE_BACKUP_DIR, else Windows %LOCALAPPDATA%\\flowstate\\backups,
4
+ macOS ~/Library/Application Support/flowstate/backups,
5
+ Linux ~/.local/share/flowstate/backups
6
+ config: FLOWSTATE_CONFIG_DIR, else Windows %APPDATA%\\flowstate,
7
+ macOS ~/Library/Application Support/flowstate, Linux ~/.config/flowstate
8
+
9
+ None of these is inside the (possibly git-synced) data folder unless the user says so.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import platform
15
+ import threading
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+
20
+ def _system() -> str:
21
+ return platform.system()
22
+
23
+
24
+ def data_root(home: Optional[Path] = None, system: Optional[str] = None) -> Path:
25
+ system, h = system or _system(), Path(home) if home else Path.home()
26
+ if system == "Windows":
27
+ base = Path(os.environ["LOCALAPPDATA"]) if (not home and os.environ.get("LOCALAPPDATA")) \
28
+ else h / "AppData" / "Local"
29
+ return base / "flowstate"
30
+ if system == "Darwin":
31
+ return h / "Library" / "Application Support" / "flowstate"
32
+ return h / ".local" / "share" / "flowstate"
33
+
34
+
35
+ def backup_dir(home: Optional[Path] = None, system: Optional[str] = None) -> Path:
36
+ if not home and os.environ.get("FLOWSTATE_BACKUP_DIR"):
37
+ return Path(os.environ["FLOWSTATE_BACKUP_DIR"]).expanduser()
38
+ return data_root(home, system) / "backups"
39
+
40
+
41
+ def config_dir(home: Optional[Path] = None, system: Optional[str] = None) -> Path:
42
+ if not home and os.environ.get("FLOWSTATE_CONFIG_DIR"):
43
+ return Path(os.environ["FLOWSTATE_CONFIG_DIR"]).expanduser()
44
+ system, h = system or _system(), Path(home) if home else Path.home()
45
+ if system == "Windows":
46
+ base = Path(os.environ["APPDATA"]) if (not home and os.environ.get("APPDATA")) \
47
+ else h / "AppData" / "Roaming"
48
+ return base / "flowstate"
49
+ if system == "Darwin":
50
+ return h / "Library" / "Application Support" / "flowstate"
51
+ return h / ".config" / "flowstate"
52
+
53
+
54
+ # ---------------------------------------------------------------- settings file
55
+ def config_file() -> Path:
56
+ return config_dir() / "config.json"
57
+
58
+
59
+ class ConfigUnreadable(Exception):
60
+ """config.json exists but isn't a JSON object FlowState can read (a hand-edit typo, or a
61
+ file another program is half-way through writing)."""
62
+
63
+ def __init__(self, path: Path):
64
+ super().__init__(f"FlowState's settings file {path} can't be read (it isn't valid JSON). "
65
+ f"Nothing was changed. Fix or delete that file, then try again.")
66
+ self.path = path
67
+
68
+
69
+ def _read_config(p: Path) -> dict:
70
+ """The settings, {} when there is no file; ConfigUnreadable when there is one that doesn't
71
+ parse. On Windows a reader can be refused for a moment while a writer replaces the file,
72
+ so a refused read is tried again briefly first."""
73
+ import time
74
+ deadline = time.monotonic() + 1.0
75
+ while True:
76
+ try:
77
+ text = p.read_text(encoding="utf-8")
78
+ break
79
+ except FileNotFoundError:
80
+ return {}
81
+ except PermissionError:
82
+ if time.monotonic() > deadline:
83
+ raise ConfigUnreadable(p) from None
84
+ time.sleep(0.02)
85
+ except OSError:
86
+ raise ConfigUnreadable(p) from None
87
+ try:
88
+ data = json.loads(text)
89
+ except ValueError:
90
+ raise ConfigUnreadable(p) from None
91
+ if not isinstance(data, dict):
92
+ raise ConfigUnreadable(p)
93
+ return data
94
+
95
+
96
+ def load_config(strict: bool = False) -> dict:
97
+ """FlowState's own settings (no secrets ever go in here). A file that can't be read counts
98
+ as no settings, unless `strict` (then ConfigUnreadable): a setting that must fail safe
99
+ (updates.self_update_allowed, the name sent to the relay) and every writer use strict."""
100
+ try:
101
+ return _read_config(config_file())
102
+ except ConfigUnreadable:
103
+ if strict:
104
+ raise
105
+ return {}
106
+
107
+
108
+ def replace_file(tmp: Path, p: Path, wait: float = 2.0) -> None:
109
+ """os.replace(tmp, p), the last step of an atomic write. On Windows the replace is refused
110
+ (access denied) while another process has `p` open, e.g. `flowstate relay status` reading
111
+ the file the relay agent rewrites; readers hold it for moments, so try again briefly."""
112
+ import time
113
+ deadline = time.monotonic() + wait
114
+ while True:
115
+ try:
116
+ os.replace(tmp, p)
117
+ return
118
+ except PermissionError:
119
+ if os.name != "nt" or time.monotonic() > deadline:
120
+ raise
121
+ time.sleep(0.02)
122
+
123
+
124
+ def save_config(data: dict) -> Path:
125
+ """Write the whole settings file atomically (a temp file of its own, then replace). Use
126
+ update_config to change one setting: it keeps the others."""
127
+ import secrets
128
+ from .privatefs import private_dir, write_private
129
+ p = config_file()
130
+ private_dir(p.parent)
131
+ tmp = p.with_name(f"{p.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
132
+ try:
133
+ write_private(tmp, json.dumps(data, indent=2) + "\n") # owner-only (audit D9)
134
+ replace_file(tmp, p)
135
+ finally:
136
+ if tmp.exists():
137
+ tmp.unlink()
138
+ return p
139
+
140
+
141
+ LOCK_WAIT = 5.0 # seconds a settings writer waits for another one
142
+
143
+
144
+ def _try_lock(fd: int) -> bool:
145
+ """An OS lock on the open lock file, without waiting: True if we have it, False if another
146
+ process (or thread) holds it. The OS drops it when the holder exits, so a writer that
147
+ crashed never blocks the next one. A filesystem without locks counts as "have it"."""
148
+ import errno
149
+ try:
150
+ if os.name == "nt":
151
+ import msvcrt
152
+ os.lseek(fd, 0, os.SEEK_SET)
153
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
154
+ else:
155
+ import fcntl
156
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
157
+ return True
158
+ except OSError as e:
159
+ if e.errno in (errno.EAGAIN, errno.EACCES, errno.EWOULDBLOCK, errno.EDEADLK):
160
+ return False
161
+ return True # e.g. ENOLCK / ENOTSUP on an odd filesystem: go ahead as before 2.4.4
162
+
163
+
164
+ def _unlock(fd: int) -> None:
165
+ try:
166
+ if os.name == "nt":
167
+ import msvcrt
168
+ os.lseek(fd, 0, os.SEEK_SET)
169
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
170
+ else:
171
+ import fcntl
172
+ fcntl.flock(fd, fcntl.LOCK_UN)
173
+ except OSError:
174
+ pass
175
+
176
+
177
+ _thread_lock = threading.Lock() # threads of one process take turns too (and wait less)
178
+
179
+
180
+ def _quiet_unlink(f: Path) -> None:
181
+ try:
182
+ f.unlink()
183
+ except OSError:
184
+ pass
185
+
186
+
187
+ def _lock(p: Path):
188
+ """An OS file lock (flock / msvcrt) on a lock file next to config.json, held while one
189
+ writer reads, changes and writes the settings, so two writers at once can't lose each
190
+ other's change. Nothing to go stale: the OS releases it when its holder exits (audit
191
+ N-F23: a lock left by a crashed writer no longer blocks for 30 s, and two waiting
192
+ writers can't both "break" one). After locking, the file must still be the one at the
193
+ path (the last holder removes it when done), else it starts over."""
194
+ import contextlib
195
+ import time
196
+
197
+ @contextlib.contextmanager
198
+ def held():
199
+ lock = p.with_name(p.name + ".lock")
200
+ deadline = time.monotonic() + LOCK_WAIT
201
+
202
+ def busy():
203
+ raise TimeoutError(f"Another FlowState command is changing {p}; try again "
204
+ f"in a moment.") from None
205
+ if not _thread_lock.acquire(timeout=LOCK_WAIT):
206
+ busy()
207
+ try:
208
+ while True:
209
+ # Windows refuses the open (and the stat) while the last holder's removal of
210
+ # the file is still going through: a busy lock, not an error.
211
+ try:
212
+ fd = os.open(str(lock), os.O_CREAT | os.O_RDWR, 0o600)
213
+ except PermissionError:
214
+ fd = None
215
+ if fd is not None:
216
+ if _try_lock(fd):
217
+ try:
218
+ same = os.path.samestat(os.fstat(fd), os.stat(str(lock)))
219
+ except (FileNotFoundError, PermissionError):
220
+ same = False
221
+ if same:
222
+ break
223
+ _unlock(fd) # its holder removed it as we opened it: take the new one
224
+ os.close(fd)
225
+ if time.monotonic() > deadline:
226
+ busy()
227
+ time.sleep(0.02)
228
+ try:
229
+ yield
230
+ finally:
231
+ # POSIX: remove it while still holding it, so whoever has it open next sees a
232
+ # file that is no longer at the path and starts over. Windows can't remove an
233
+ # open file: unlock and close first; if another writer opened it meanwhile, the
234
+ # removal is refused and that writer keeps the same file.
235
+ if os.name != "nt":
236
+ _quiet_unlink(lock)
237
+ _unlock(fd)
238
+ os.close(fd)
239
+ if os.name == "nt":
240
+ _quiet_unlink(lock)
241
+ finally:
242
+ _thread_lock.release()
243
+ return held()
244
+
245
+
246
+ def update_config(change) -> Path:
247
+ """Change the settings: `change(cfg)` edits the dict in place, under the lock, on a strict
248
+ read (a file that exists but can't be read raises ConfigUnreadable and is left as it is,
249
+ so one typo can't wipe the other settings)."""
250
+ from .privatefs import private_dir
251
+ p = config_file()
252
+ private_dir(p.parent) # owner-only when FlowState makes it (audit D9)
253
+ with _lock(p):
254
+ cfg = _read_config(p)
255
+ change(cfg)
256
+ return save_config(cfg)
257
+
258
+
259
+ def inside_git_worktree(path: Path) -> Optional[Path]:
260
+ """The git work tree containing `path`, if any (checks for a .git entry upwards)."""
261
+ p = Path(path).resolve()
262
+ for d in [p] + list(p.parents):
263
+ if (d / ".git").exists():
264
+ return d
265
+ return None