@softspark/ai-toolkit 4.14.1 → 4.15.0

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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +11 -10
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/CLAUDE.md.template +3 -0
  5. package/app/hooks/_search-capability.sh +3 -2
  6. package/app/hooks/stop-search-check.sh +2 -1
  7. package/benchmarks/ecosystem-doctor-snapshot.json +73 -31
  8. package/kb/procedures/maintenance-sop.md +26 -13
  9. package/kb/procedures/release-verification-sop.md +41 -36
  10. package/kb/reference/architecture-overview.md +23 -7
  11. package/kb/reference/codex-cli-compatibility.md +96 -36
  12. package/kb/reference/extension-api.md +52 -9
  13. package/kb/reference/global-install-model.md +53 -21
  14. package/kb/reference/hooks-catalog.md +44 -8
  15. package/kb/reference/mcp-editor-compatibility.md +27 -6
  16. package/kb/reference/mcp-templates.md +12 -6
  17. package/kb/reference/opencode-compatibility.md +13 -7
  18. package/kb/reference/plugin-pack-conventions.md +7 -7
  19. package/kb/reference/skills-catalog.md +3 -3
  20. package/kb/reference/supported-tools-registry.md +19 -17
  21. package/kb/reference/windows-support.md +26 -3
  22. package/llms-full.txt +443 -180
  23. package/llms.txt +1 -1
  24. package/manifest.json +1 -1
  25. package/package.json +2 -2
  26. package/scripts/codex_skill_adapter.py +448 -198
  27. package/scripts/dir_rules_shared.py +2 -11
  28. package/scripts/ecosystem_tools.json +29 -8
  29. package/scripts/emission.py +5 -91
  30. package/scripts/generate_agents_md.py +4 -87
  31. package/scripts/generate_codex.py +5 -95
  32. package/scripts/generate_codex_agents.py +242 -0
  33. package/scripts/generate_codex_hooks.py +648 -55
  34. package/scripts/generate_codex_skills.py +15 -6
  35. package/scripts/generate_copilot.py +771 -74
  36. package/scripts/generate_copilot_hooks.py +606 -0
  37. package/scripts/generate_cursor_hooks.py +453 -121
  38. package/scripts/generate_opencode_commands.py +4 -6
  39. package/scripts/inject_hook_cli.py +770 -205
  40. package/scripts/injection.py +102 -23
  41. package/scripts/install_steps/ai_tools.py +123 -83
  42. package/scripts/instruction_core.py +95 -0
  43. package/scripts/mcp_editors.py +934 -80
  44. package/scripts/mcp_manager.py +46 -26
  45. package/scripts/plugin.py +291 -114
  46. package/scripts/secure_fs.py +538 -0
  47. package/scripts/uninstall.py +1279 -208
@@ -1,9 +1,16 @@
1
1
  #!/usr/bin/env python3
2
2
  """Editor-specific MCP config adapters for ai-toolkit."""
3
+
3
4
  from __future__ import annotations
4
5
 
5
6
  import copy
7
+ import errno
6
8
  import json
9
+ import math
10
+ import os
11
+ import re
12
+ import tempfile
13
+ from dataclasses import dataclass
7
14
  from pathlib import Path
8
15
 
9
16
  try:
@@ -41,6 +48,13 @@ EDITOR_SPECS: dict[str, dict[str, str | None]] = {
41
48
  "format": "json",
42
49
  "doc_scope": "project + global",
43
50
  },
51
+ "antigravity": {
52
+ "label": "Google Antigravity",
53
+ "project_path": ".agents/mcp_config.json",
54
+ "global_path": ".gemini/config/mcp_config.json",
55
+ "format": "json",
56
+ "doc_scope": "project + global",
57
+ },
44
58
  "roo": {
45
59
  "label": "Roo Code",
46
60
  "project_path": ".roo/mcp.json",
@@ -71,19 +85,82 @@ EDITOR_SPECS: dict[str, dict[str, str | None]] = {
71
85
  },
72
86
  "codex": {
73
87
  "label": "Codex CLI",
74
- "project_path": None,
88
+ "project_path": ".codex/config.toml",
75
89
  "global_path": ".codex/config.toml",
76
90
  "format": "toml",
77
- "doc_scope": "global",
91
+ "doc_scope": "project + global",
78
92
  },
79
93
  }
80
94
 
81
95
 
96
+ CODEX_MCP_BLOCK_START = "# >>> ai-toolkit managed Codex MCP servers >>>"
97
+ CODEX_MCP_BLOCK_END = "# <<< ai-toolkit managed Codex MCP servers <<<"
98
+
99
+ _TOML_BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
100
+ _CODEX_SHARED_SERVER_KEYS = frozenset(
101
+ {
102
+ "startup_timeout_sec",
103
+ "startup_timeout_ms",
104
+ "tool_timeout_sec",
105
+ "enabled",
106
+ "required",
107
+ "enabled_tools",
108
+ "disabled_tools",
109
+ "scopes",
110
+ "oauth_resource",
111
+ "default_tools_approval_mode",
112
+ "tools",
113
+ }
114
+ )
115
+ _CODEX_STDIO_SERVER_KEYS = frozenset(
116
+ {
117
+ "command",
118
+ "args",
119
+ "env",
120
+ "env_vars",
121
+ "cwd",
122
+ "experimental_environment",
123
+ }
124
+ )
125
+ _CODEX_HTTP_SERVER_KEYS = frozenset(
126
+ {
127
+ "url",
128
+ "auth",
129
+ "bearer_token_env_var",
130
+ "http_headers",
131
+ "env_http_headers",
132
+ }
133
+ )
134
+ _CODEX_PORTABLE_TRANSPORT_KEYS = {
135
+ "http": "url",
136
+ "local": "command",
137
+ "stdio": "command",
138
+ }
139
+ _CODEX_APPROVAL_MODES = frozenset({"auto", "prompt", "writes", "approve"})
140
+ _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS = frozenset(
141
+ {
142
+ errno.EBADF,
143
+ errno.EINVAL,
144
+ getattr(errno, "ENOTSUP", errno.EINVAL),
145
+ getattr(errno, "EOPNOTSUPP", errno.EINVAL),
146
+ }
147
+ )
148
+
149
+
82
150
  PROJECT_SCOPED_EDITORS = {
83
151
  name for name, spec in EDITOR_SPECS.items() if spec.get("project_path")
84
152
  }
85
153
 
86
154
 
155
+ @dataclass(frozen=True, slots=True)
156
+ class ConfigUpdate:
157
+ """A preflighted config-file replacement used by an MCP transaction."""
158
+
159
+ path: Path
160
+ original: bytes | None
161
+ content: bytes | None
162
+
163
+
87
164
  def supported_editors() -> list[str]:
88
165
  """Return all editor ids with native MCP adapters."""
89
166
  return sorted(EDITOR_SPECS)
@@ -94,17 +171,50 @@ def editor_rows() -> list[dict[str, str]]:
94
171
  rows: list[dict[str, str]] = []
95
172
  for name in supported_editors():
96
173
  spec = EDITOR_SPECS[name]
97
- rows.append({
98
- "name": name,
99
- "label": str(spec["label"]),
100
- "scope": str(spec["doc_scope"]),
101
- "project_path": str(spec.get("project_path") or "—"),
102
- "global_path": str(spec.get("global_path") or "—"),
103
- "format": str(spec["format"]),
104
- })
174
+ rows.append(
175
+ {
176
+ "name": name,
177
+ "label": str(spec["label"]),
178
+ "scope": str(spec["doc_scope"]),
179
+ "project_path": str(spec.get("project_path") or "—"),
180
+ "global_path": str(spec.get("global_path") or "—"),
181
+ "format": str(spec["format"]),
182
+ }
183
+ )
105
184
  return rows
106
185
 
107
186
 
187
+ def _resolve_global_config_root(
188
+ *,
189
+ home: Path | None,
190
+ env_name: str,
191
+ default_dir: str,
192
+ ) -> Path:
193
+ """Resolve an editor config root without following an environment symlink."""
194
+ if home is not None:
195
+ return (home / default_dir).expanduser().absolute()
196
+
197
+ configured = os.environ.get(env_name, "").strip()
198
+ if not configured:
199
+ return (Path.home() / default_dir).absolute()
200
+
201
+ config_root = Path(configured).expanduser()
202
+ if not config_root.is_absolute():
203
+ raise ValueError(f"Configured {env_name} must be absolute: {configured}")
204
+ config_root = config_root.absolute()
205
+ if config_root.is_symlink():
206
+ raise RuntimeError(
207
+ f"Configured {env_name} must not be a symlink: {config_root}"
208
+ )
209
+ if not config_root.exists():
210
+ raise FileNotFoundError(f"Configured {env_name} does not exist: {config_root}")
211
+ if not config_root.is_dir():
212
+ raise NotADirectoryError(
213
+ f"Configured {env_name} is not a directory: {config_root}"
214
+ )
215
+ return config_root
216
+
217
+
108
218
  def resolve_editor_path(
109
219
  editor: str,
110
220
  scope: str,
@@ -119,13 +229,29 @@ def resolve_editor_path(
119
229
  if scope == "project":
120
230
  rel = spec.get("project_path")
121
231
  if not rel:
122
- raise ValueError(f"Editor '{editor}' does not support project-scoped MCP config")
232
+ raise ValueError(
233
+ f"Editor '{editor}' does not support project-scoped MCP config"
234
+ )
123
235
  base = project_dir or Path.cwd()
124
236
  return base / str(rel)
125
237
  if scope == "global":
126
238
  rel = spec.get("global_path")
127
239
  if not rel:
128
240
  raise ValueError(f"Editor '{editor}' does not support global MCP config")
241
+ if editor == "copilot":
242
+ copilot_home = _resolve_global_config_root(
243
+ home=home,
244
+ env_name="COPILOT_HOME",
245
+ default_dir=".copilot",
246
+ )
247
+ return copilot_home / "mcp-config.json"
248
+ if editor == "codex":
249
+ codex_home = _resolve_global_config_root(
250
+ home=home,
251
+ env_name="CODEX_HOME",
252
+ default_dir=".codex",
253
+ )
254
+ return codex_home / "config.toml"
129
255
  return (home or Path.home()) / str(rel)
130
256
  raise ValueError(f"Unsupported scope: {scope}")
131
257
 
@@ -133,10 +259,10 @@ def resolve_editor_path(
133
259
  def load_project_mcp_servers(project_dir: Path) -> dict:
134
260
  """Load `.mcp.json` servers from a project directory."""
135
261
  config_path = project_dir / ".mcp.json"
136
- if not config_path.is_file():
262
+ _assert_safe_config_path(config_path)
263
+ if not config_path.exists():
137
264
  raise FileNotFoundError(f"{config_path} not found")
138
- with open(config_path, encoding="utf-8") as f:
139
- data = json.load(f)
265
+ data = load_json_config(config_path)
140
266
  servers = data.get("mcpServers", {})
141
267
  if not isinstance(servers, dict):
142
268
  raise ValueError(f"{config_path} has invalid mcpServers data")
@@ -152,7 +278,28 @@ def install_servers(
152
278
  home: Path | None = None,
153
279
  ) -> list[Path]:
154
280
  """Merge servers into native editor config files."""
155
- updated: list[Path] = []
281
+ updates = prepare_install_servers(
282
+ editors,
283
+ servers,
284
+ scope=scope,
285
+ project_dir=project_dir,
286
+ home=home,
287
+ )
288
+ apply_config_updates(updates)
289
+ return [update.path for update in updates]
290
+
291
+
292
+ def prepare_install_servers(
293
+ editors: list[str],
294
+ servers: dict,
295
+ *,
296
+ scope: str,
297
+ project_dir: Path | None = None,
298
+ home: Path | None = None,
299
+ ) -> list[ConfigUpdate]:
300
+ """Preflight all native editor merges without mutating any destination."""
301
+ updates: list[ConfigUpdate] = []
302
+ seen_paths: set[Path] = set()
156
303
  for editor in editors:
157
304
  path = resolve_editor_path(
158
305
  editor,
@@ -160,12 +307,15 @@ def install_servers(
160
307
  project_dir=project_dir,
161
308
  home=home,
162
309
  )
310
+ identity = path.absolute()
311
+ if identity in seen_paths:
312
+ continue
313
+ seen_paths.add(identity)
163
314
  if EDITOR_SPECS[editor]["format"] == "toml":
164
- _merge_toml_servers(path, servers)
315
+ updates.append(_prepare_merge_toml_servers(path, servers))
165
316
  else:
166
- _merge_json_servers(path, editor, servers)
167
- updated.append(path)
168
- return updated
317
+ updates.append(_prepare_merge_json_servers(path, editor, servers))
318
+ return updates
169
319
 
170
320
 
171
321
  def remove_servers(
@@ -177,7 +327,28 @@ def remove_servers(
177
327
  home: Path | None = None,
178
328
  ) -> list[Path]:
179
329
  """Remove servers from native editor config files."""
180
- updated: list[Path] = []
330
+ updates = prepare_remove_servers(
331
+ editors,
332
+ server_names,
333
+ scope=scope,
334
+ project_dir=project_dir,
335
+ home=home,
336
+ )
337
+ apply_config_updates(updates)
338
+ return [update.path for update in updates]
339
+
340
+
341
+ def prepare_remove_servers(
342
+ editors: list[str],
343
+ server_names: list[str],
344
+ *,
345
+ scope: str,
346
+ project_dir: Path | None = None,
347
+ home: Path | None = None,
348
+ ) -> list[ConfigUpdate]:
349
+ """Preflight all native editor removals without mutating any destination."""
350
+ updates: list[ConfigUpdate] = []
351
+ seen_paths: set[Path] = set()
181
352
  for editor in editors:
182
353
  path = resolve_editor_path(
183
354
  editor,
@@ -185,12 +356,15 @@ def remove_servers(
185
356
  project_dir=project_dir,
186
357
  home=home,
187
358
  )
359
+ identity = path.absolute()
360
+ if identity in seen_paths:
361
+ continue
362
+ seen_paths.add(identity)
188
363
  if EDITOR_SPECS[editor]["format"] == "toml":
189
- _remove_toml_servers(path, server_names)
364
+ updates.append(_prepare_remove_toml_servers(path, server_names))
190
365
  else:
191
- _remove_json_servers(path, server_names)
192
- updated.append(path)
193
- return updated
366
+ updates.append(_prepare_remove_json_servers(path, server_names))
367
+ return updates
194
368
 
195
369
 
196
370
  def sync_project_mcp_to_editors(project_dir: Path, editors: list[str]) -> list[Path]:
@@ -199,7 +373,8 @@ def sync_project_mcp_to_editors(project_dir: Path, editors: list[str]) -> list[P
199
373
  Claude project settings are always synced when `.mcp.json` exists.
200
374
  """
201
375
  config_path = project_dir / ".mcp.json"
202
- if not config_path.is_file():
376
+ _assert_safe_config_path(config_path)
377
+ if not config_path.exists():
203
378
  return []
204
379
 
205
380
  servers = load_project_mcp_servers(project_dir)
@@ -214,23 +389,122 @@ def sync_project_mcp_to_editors(project_dir: Path, editors: list[str]) -> list[P
214
389
 
215
390
 
216
391
  def _load_json_file(path: Path) -> dict:
217
- if not path.is_file():
218
- return {}
219
- with open(path, encoding="utf-8") as f:
220
- data = json.load(f)
392
+ _original, data = _load_json_document(path)
393
+ return data
394
+
395
+
396
+ def _load_json_document(path: Path) -> tuple[bytes | None, dict]:
397
+ original = _read_optional_file(path)
398
+ if original is None:
399
+ return None, {}
400
+ try:
401
+ text = original.decode("utf-8")
402
+ except UnicodeDecodeError as error:
403
+ raise ValueError(f"{path} is not valid UTF-8 JSON: {error}") from error
404
+ try:
405
+ data = json.loads(text)
406
+ except json.JSONDecodeError as error:
407
+ raise ValueError(f"{path} contains invalid JSON: {error}") from error
221
408
  if not isinstance(data, dict):
222
409
  raise ValueError(f"{path} must contain a JSON object")
223
- return data
410
+ return original, data
224
411
 
225
412
 
226
- def _write_json_file(path: Path, data: dict) -> None:
227
- path.parent.mkdir(parents=True, exist_ok=True)
228
- with open(path, "w", encoding="utf-8") as f:
229
- json.dump(data, f, indent=2)
230
- f.write("\n")
413
+ def load_json_config(path: Path) -> dict:
414
+ """Load a native JSON config after validating its destination path."""
415
+ return _load_json_file(path)
416
+
417
+
418
+ def prepare_json_config(path: Path, data: dict) -> ConfigUpdate:
419
+ """Build a validated JSON replacement without writing it."""
420
+ if not isinstance(data, dict):
421
+ raise ValueError(f"{path} must contain a JSON object")
422
+ original, _existing = _load_json_document(path)
423
+ content = (json.dumps(data, indent=2) + "\n").encode("utf-8")
424
+ return ConfigUpdate(path=path, original=original, content=content)
425
+
426
+
427
+ def write_json_config(path: Path, data: dict) -> None:
428
+ """Atomically write a native JSON MCP document without following symlinks."""
429
+ apply_config_updates([prepare_json_config(path, data)])
430
+
431
+
432
+ def apply_config_updates(updates: list[ConfigUpdate]) -> None:
433
+ """Apply preflighted file updates atomically, rolling back on any failure."""
434
+ unique_updates = _coalesce_config_updates(updates)
435
+ pending = [
436
+ update
437
+ for update in unique_updates
438
+ if update.content is not None and update.content != update.original
439
+ ]
440
+
441
+ # Validate every snapshot before the first write. This catches symlinks,
442
+ # concurrent changes, invalid destination types, and stale plans without
443
+ # leaving a partially updated editor set.
444
+ for update in pending:
445
+ _verify_config_snapshot(update)
446
+
447
+ attempted: list[ConfigUpdate] = []
448
+ try:
449
+ for update in pending:
450
+ _verify_config_snapshot(update)
451
+ attempted.append(update)
452
+ _atomic_write_bytes(update.path, update.content)
453
+ except Exception as error:
454
+ rollback_errors: list[str] = []
455
+ for update in reversed(attempted):
456
+ try:
457
+ _rollback_config_update(update)
458
+ except Exception as rollback_error: # pragma: no cover - catastrophic I/O
459
+ rollback_errors.append(f"{update.path}: {rollback_error}")
460
+ if rollback_errors:
461
+ details = "; ".join(rollback_errors)
462
+ raise RuntimeError(
463
+ f"MCP config update failed ({error}); rollback also failed: {details}"
464
+ ) from error
465
+ raise
466
+
467
+
468
+ def _coalesce_config_updates(updates: list[ConfigUpdate]) -> list[ConfigUpdate]:
469
+ unique: dict[Path, ConfigUpdate] = {}
470
+ for update in updates:
471
+ identity = update.path.absolute()
472
+ previous = unique.get(identity)
473
+ if previous is None:
474
+ unique[identity] = update
475
+ continue
476
+ if previous.original != update.original or previous.content != update.content:
477
+ raise ValueError(f"Conflicting MCP config updates for {update.path}")
478
+ return list(unique.values())
479
+
480
+
481
+ def _verify_config_snapshot(update: ConfigUpdate) -> None:
482
+ current = _read_optional_file(update.path)
483
+ if current != update.original:
484
+ raise RuntimeError(
485
+ f"MCP config changed after preflight; refusing to overwrite: {update.path}"
486
+ )
487
+
488
+
489
+ def _rollback_config_update(update: ConfigUpdate) -> None:
490
+ current = _read_optional_file(update.path)
491
+ if current == update.original:
492
+ return
493
+ if current != update.content:
494
+ raise RuntimeError(
495
+ f"MCP config changed during rollback; refusing to overwrite: {update.path}"
496
+ )
497
+ if update.original is None:
498
+ _assert_safe_config_path(update.path)
499
+ update.path.unlink()
500
+ _fsync_directory(update.path.parent)
501
+ return
502
+ _atomic_write_bytes(update.path, update.original)
231
503
 
232
504
 
233
505
  def _normalize_server(editor: str, server: dict) -> dict:
506
+ if editor == "antigravity":
507
+ return _normalize_antigravity_server(server)
234
508
  data = copy.deepcopy(server)
235
509
  if editor == "copilot":
236
510
  if "url" in data:
@@ -241,67 +515,372 @@ def _normalize_server(editor: str, server: dict) -> dict:
241
515
  return data
242
516
 
243
517
 
244
- def _merge_json_servers(path: Path, editor: str, servers: dict) -> None:
245
- data = _load_json_file(path)
518
+ def _normalize_antigravity_server(server: dict) -> dict:
519
+ """Validate Antigravity's native MCP schema without rewriting transports."""
520
+ if not isinstance(server, dict):
521
+ raise ValueError("Antigravity MCP server configuration must be an object")
522
+
523
+ data = copy.deepcopy(server)
524
+ data.pop("_source", None)
525
+ if "httpUrl" in data:
526
+ raise ValueError("Antigravity MCP does not support the legacy 'httpUrl' field")
527
+
528
+ transport_keys = [key for key in ("command", "serverUrl", "url") if key in data]
529
+ if len(transport_keys) != 1:
530
+ raise ValueError(
531
+ "Antigravity MCP server requires exactly one of "
532
+ "'command', 'serverUrl', or 'url'"
533
+ )
534
+ transport_key = transport_keys[0]
535
+ _require_antigravity_string(data, transport_key)
536
+
537
+ if transport_key == "command":
538
+ _validate_antigravity_stdio(data)
539
+ else:
540
+ _validate_antigravity_remote(data)
541
+ if "disabled" in data and not isinstance(data["disabled"], bool):
542
+ raise ValueError("Antigravity MCP 'disabled' must be a boolean")
543
+ _validate_antigravity_string_list(data, "disabledTools")
544
+ return data
545
+
546
+
547
+ def _validate_antigravity_stdio(data: dict) -> None:
548
+ incompatible = {"headers", "authProviderType", "oauth"} & set(data)
549
+ if incompatible:
550
+ fields = ", ".join(sorted(incompatible))
551
+ raise ValueError(f"Incompatible Antigravity STDIO MCP field(s): {fields}")
552
+ _validate_antigravity_string_list(data, "args")
553
+ _validate_antigravity_string_map(data, "env")
554
+ if "cwd" in data:
555
+ _require_antigravity_string(data, "cwd")
556
+
557
+
558
+ def _validate_antigravity_remote(data: dict) -> None:
559
+ incompatible = {"args", "env", "cwd"} & set(data)
560
+ if incompatible:
561
+ fields = ", ".join(sorted(incompatible))
562
+ raise ValueError(f"Incompatible Antigravity remote MCP field(s): {fields}")
563
+ _validate_antigravity_string_map(data, "headers")
564
+ if "authProviderType" in data:
565
+ _require_antigravity_string(data, "authProviderType")
566
+ if "oauth" in data and not isinstance(data["oauth"], dict):
567
+ raise ValueError("Antigravity MCP 'oauth' must be an object")
568
+
569
+
570
+ def _require_antigravity_string(data: dict, key: str) -> None:
571
+ value = data.get(key)
572
+ if not isinstance(value, str) or not value:
573
+ raise ValueError(f"Antigravity MCP '{key}' must be a non-empty string")
574
+
575
+
576
+ def _validate_antigravity_string_list(data: dict, key: str) -> None:
577
+ if key not in data:
578
+ return
579
+ value = data[key]
580
+ if not isinstance(value, list) or not all(
581
+ isinstance(item, str) and item for item in value
582
+ ):
583
+ raise ValueError(f"Antigravity MCP '{key}' must be a list of non-empty strings")
584
+
585
+
586
+ def _validate_antigravity_string_map(data: dict, key: str) -> None:
587
+ if key not in data:
588
+ return
589
+ value = data[key]
590
+ if not isinstance(value, dict) or not all(
591
+ isinstance(item_key, str) and item_key and isinstance(item_value, str)
592
+ for item_key, item_value in value.items()
593
+ ):
594
+ raise ValueError(
595
+ f"Antigravity MCP '{key}' must map non-empty strings to strings"
596
+ )
597
+
598
+
599
+ def _prepare_merge_json_servers(
600
+ path: Path,
601
+ editor: str,
602
+ servers: dict,
603
+ ) -> ConfigUpdate:
604
+ original, data = _load_json_document(path)
246
605
  bucket = data.setdefault("mcpServers", {})
247
606
  if not isinstance(bucket, dict):
248
607
  raise ValueError(f"{path} has invalid mcpServers data")
249
608
  for key, value in servers.items():
250
609
  bucket[key] = _normalize_server(editor, value)
251
- _write_json_file(path, data)
610
+ content = (json.dumps(data, indent=2) + "\n").encode("utf-8")
611
+ return ConfigUpdate(path=path, original=original, content=content)
252
612
 
253
613
 
254
- def _remove_json_servers(path: Path, server_names: list[str]) -> None:
255
- if not path.is_file():
256
- return
257
- data = _load_json_file(path)
614
+ def _prepare_remove_json_servers(
615
+ path: Path,
616
+ server_names: list[str],
617
+ ) -> ConfigUpdate:
618
+ original, data = _load_json_document(path)
619
+ if original is None:
620
+ return ConfigUpdate(path=path, original=None, content=None)
258
621
  bucket = data.get("mcpServers", {})
259
622
  if not isinstance(bucket, dict):
260
623
  raise ValueError(f"{path} has invalid mcpServers data")
624
+ changed = False
261
625
  for name in server_names:
262
- bucket.pop(name, None)
626
+ if name in bucket:
627
+ del bucket[name]
628
+ changed = True
629
+ if not changed:
630
+ return ConfigUpdate(path=path, original=original, content=original)
263
631
  data["mcpServers"] = bucket
264
- _write_json_file(path, data)
632
+ content = (json.dumps(data, indent=2) + "\n").encode("utf-8")
633
+ return ConfigUpdate(path=path, original=original, content=content)
265
634
 
266
635
 
267
- def _load_toml_file(path: Path) -> dict:
268
- if not path.is_file():
269
- return {}
270
- if tomllib is None: # pragma: no cover
271
- raise RuntimeError("tomllib is unavailable")
272
- return tomllib.loads(path.read_text(encoding="utf-8"))
636
+ def _prepare_merge_toml_servers(path: Path, servers: dict) -> ConfigUpdate:
637
+ original_bytes, original = _load_toml_document(path)
638
+ base, managed = _split_codex_managed_block(original, path)
639
+ normalized = {
640
+ _validate_server_name(name): _normalize_toml_server(value)
641
+ for name, value in servers.items()
642
+ }
643
+ base = _remove_selected_toml_server_tables(base, set(normalized), path)
644
+ managed.update(normalized)
645
+ rendered = _compose_codex_toml(base, managed)
646
+ _parse_toml(rendered, path)
647
+ return ConfigUpdate(
648
+ path=path,
649
+ original=original_bytes,
650
+ content=rendered.encode("utf-8"),
651
+ )
273
652
 
274
653
 
275
- def _merge_toml_servers(path: Path, servers: dict) -> None:
276
- data = _load_toml_file(path)
277
- bucket = data.setdefault("mcp_servers", {})
278
- if not isinstance(bucket, dict):
279
- raise ValueError(f"{path} has invalid mcp_servers data")
280
- for key, value in servers.items():
281
- bucket[key] = _normalize_toml_server(value)
282
- _write_toml_file(path, data)
654
+ def _prepare_remove_toml_servers(
655
+ path: Path,
656
+ server_names: list[str],
657
+ ) -> ConfigUpdate:
658
+ original_bytes, original = _load_toml_document(path)
659
+ if original_bytes is None:
660
+ return ConfigUpdate(path=path, original=None, content=None)
661
+ base, managed = _split_codex_managed_block(original, path)
662
+ normalized_names = {_validate_server_name(name) for name in server_names}
663
+ base = _remove_selected_toml_server_tables(base, normalized_names, path)
664
+ for name in normalized_names:
665
+ managed.pop(name, None)
666
+ rendered = _compose_codex_toml(base, managed)
667
+ _parse_toml(rendered, path)
668
+ return ConfigUpdate(
669
+ path=path,
670
+ original=original_bytes,
671
+ content=rendered.encode("utf-8"),
672
+ )
283
673
 
284
674
 
285
- def _remove_toml_servers(path: Path, server_names: list[str]) -> None:
286
- if not path.is_file():
675
+ def _normalize_toml_server(server: dict) -> dict:
676
+ if not isinstance(server, dict):
677
+ raise ValueError("Codex MCP server configuration must be an object")
678
+
679
+ source = copy.deepcopy(server)
680
+ source.pop("_source", None)
681
+ _strip_compatible_codex_transport_type(source)
682
+ if "tools" in source and not isinstance(source["tools"], dict):
683
+ raise ValueError(
684
+ "Codex MCP 'tools' must be a per-tool policy object, not a Copilot tool list"
685
+ )
686
+
687
+ data = {key: value for key, value in source.items() if value not in (None, {}, [])}
688
+ has_command = "command" in data
689
+ has_url = "url" in data
690
+ if has_command == has_url:
691
+ raise ValueError("Codex MCP server requires exactly one of 'command' or 'url'")
692
+
693
+ transport_keys = (
694
+ _CODEX_STDIO_SERVER_KEYS if has_command else _CODEX_HTTP_SERVER_KEYS
695
+ )
696
+ unknown = set(data) - transport_keys - _CODEX_SHARED_SERVER_KEYS
697
+ if unknown:
698
+ raise ValueError(
699
+ "Unsupported Codex MCP field(s): " + ", ".join(sorted(unknown))
700
+ )
701
+
702
+ incompatible = set(data) & (
703
+ _CODEX_HTTP_SERVER_KEYS if has_command else _CODEX_STDIO_SERVER_KEYS
704
+ )
705
+ if incompatible:
706
+ transport = "STDIO" if has_command else "HTTP"
707
+ raise ValueError(
708
+ f"Incompatible {transport} Codex MCP field(s): "
709
+ + ", ".join(sorted(incompatible))
710
+ )
711
+
712
+ if has_command:
713
+ _require_nonempty_string(data, "command")
714
+ _validate_optional_string_list(data, "args")
715
+ _validate_optional_string_map(data, "env")
716
+ _validate_env_vars(data)
717
+ _validate_optional_string(data, "cwd")
718
+ if "experimental_environment" in data:
719
+ _require_choice(data, "experimental_environment", {"remote"})
720
+ else:
721
+ _require_nonempty_string(data, "url")
722
+ _validate_optional_string(data, "bearer_token_env_var")
723
+ _validate_optional_string_map(data, "http_headers")
724
+ _validate_optional_string_map(data, "env_http_headers")
725
+ if "auth" in data:
726
+ _require_choice(data, "auth", {"oauth", "chatgpt"})
727
+
728
+ for key in ("startup_timeout_sec", "tool_timeout_sec"):
729
+ if key in data:
730
+ value = data[key]
731
+ if (
732
+ isinstance(value, bool)
733
+ or not isinstance(value, (int, float))
734
+ or not math.isfinite(value)
735
+ or value <= 0
736
+ ):
737
+ raise ValueError(f"Codex MCP '{key}' must be a positive number")
738
+ if "startup_timeout_ms" in data:
739
+ value = data["startup_timeout_ms"]
740
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
741
+ raise ValueError(
742
+ "Codex MCP 'startup_timeout_ms' must be a positive integer"
743
+ )
744
+ for key in ("enabled", "required"):
745
+ if key in data and not isinstance(data[key], bool):
746
+ raise ValueError(f"Codex MCP '{key}' must be a boolean")
747
+ for key in ("enabled_tools", "disabled_tools"):
748
+ _validate_optional_string_list(data, key)
749
+ _validate_optional_string_list(data, "scopes")
750
+ _validate_optional_string(data, "oauth_resource")
751
+ if "default_tools_approval_mode" in data:
752
+ _require_choice(data, "default_tools_approval_mode", _CODEX_APPROVAL_MODES)
753
+ _validate_tool_policies(data)
754
+
755
+ key_order = (
756
+ "command",
757
+ "args",
758
+ "env_vars",
759
+ "cwd",
760
+ "experimental_environment",
761
+ "url",
762
+ "auth",
763
+ "bearer_token_env_var",
764
+ "startup_timeout_sec",
765
+ "startup_timeout_ms",
766
+ "tool_timeout_sec",
767
+ "enabled",
768
+ "required",
769
+ "enabled_tools",
770
+ "disabled_tools",
771
+ "default_tools_approval_mode",
772
+ "scopes",
773
+ "oauth_resource",
774
+ "env",
775
+ "http_headers",
776
+ "env_http_headers",
777
+ "tools",
778
+ )
779
+ return {key: data[key] for key in key_order if key in data}
780
+
781
+
782
+ def _strip_compatible_codex_transport_type(source: dict) -> None:
783
+ """Validate portable MCP transport metadata before rendering Codex TOML."""
784
+ if "type" not in source:
287
785
  return
288
- data = _load_toml_file(path)
289
- bucket = data.get("mcp_servers", {})
290
- if not isinstance(bucket, dict):
291
- raise ValueError(f"{path} has invalid mcp_servers data")
292
- for name in server_names:
293
- bucket.pop(name, None)
294
- data["mcp_servers"] = bucket
295
- _write_toml_file(path, data)
296
786
 
787
+ transport_type = source.pop("type")
788
+ if not isinstance(transport_type, str):
789
+ raise ValueError("Codex MCP portable 'type' must be a string")
790
+ expected_key = _CODEX_PORTABLE_TRANSPORT_KEYS.get(transport_type)
791
+ if expected_key is None:
792
+ raise ValueError(
793
+ "Codex MCP supports portable types 'http', 'local', and 'stdio'; "
794
+ f"got {transport_type!r}"
795
+ )
297
796
 
298
- def _normalize_toml_server(server: dict) -> dict:
299
- data: dict = {}
300
- for key, value in copy.deepcopy(server).items():
301
- if value in (None, {}, []):
797
+ incompatible_key = "command" if expected_key == "url" else "url"
798
+ if expected_key not in source or incompatible_key in source:
799
+ raise ValueError(
800
+ f"Codex MCP type '{transport_type}' requires '{expected_key}' "
801
+ f"and excludes '{incompatible_key}'"
802
+ )
803
+
804
+
805
+ def _validate_server_name(name: object) -> str:
806
+ if not isinstance(name, str) or not name:
807
+ raise ValueError("Codex MCP server names must be non-empty strings")
808
+ if "\n" in name or "\r" in name:
809
+ raise ValueError("Codex MCP server names cannot contain newlines")
810
+ return name
811
+
812
+
813
+ def _require_nonempty_string(data: dict, key: str) -> None:
814
+ value = data.get(key)
815
+ if not isinstance(value, str) or not value:
816
+ raise ValueError(f"Codex MCP '{key}' must be a non-empty string")
817
+
818
+
819
+ def _validate_optional_string(data: dict, key: str) -> None:
820
+ if key in data:
821
+ _require_nonempty_string(data, key)
822
+
823
+
824
+ def _validate_optional_string_list(data: dict, key: str) -> None:
825
+ if key not in data:
826
+ return
827
+ value = data[key]
828
+ if not isinstance(value, list) or not all(
829
+ isinstance(item, str) and item for item in value
830
+ ):
831
+ raise ValueError(f"Codex MCP '{key}' must be a list of non-empty strings")
832
+
833
+
834
+ def _validate_optional_string_map(data: dict, key: str) -> None:
835
+ if key not in data:
836
+ return
837
+ value = data[key]
838
+ if not isinstance(value, dict) or not all(
839
+ isinstance(item_key, str) and item_key and isinstance(item_value, str)
840
+ for item_key, item_value in value.items()
841
+ ):
842
+ raise ValueError(f"Codex MCP '{key}' must map non-empty strings to strings")
843
+
844
+
845
+ def _validate_env_vars(data: dict) -> None:
846
+ if "env_vars" not in data:
847
+ return
848
+ value = data["env_vars"]
849
+ if not isinstance(value, list):
850
+ raise ValueError("Codex MCP 'env_vars' must be a list")
851
+ for entry in value:
852
+ if isinstance(entry, str) and entry:
302
853
  continue
303
- data[key] = value
304
- return data
854
+ if not isinstance(entry, dict) or set(entry) - {"name", "source"}:
855
+ raise ValueError(
856
+ "Codex MCP env_vars entries must be strings or name/source objects"
857
+ )
858
+ if not isinstance(entry.get("name"), str) or not entry["name"]:
859
+ raise ValueError("Codex MCP env_vars object requires a non-empty name")
860
+ if entry.get("source", "local") not in {"local", "remote"}:
861
+ raise ValueError("Codex MCP env_vars source must be 'local' or 'remote'")
862
+
863
+
864
+ def _require_choice(data: dict, key: str, choices: set[str] | frozenset[str]) -> None:
865
+ if data[key] not in choices:
866
+ allowed = ", ".join(sorted(choices))
867
+ raise ValueError(f"Codex MCP '{key}' must be one of: {allowed}")
868
+
869
+
870
+ def _validate_tool_policies(data: dict) -> None:
871
+ if "tools" not in data:
872
+ return
873
+ tools = data["tools"]
874
+ if not isinstance(tools, dict):
875
+ raise ValueError("Codex MCP 'tools' must be an object")
876
+ for name, policy in tools.items():
877
+ if not isinstance(name, str) or not name or not isinstance(policy, dict):
878
+ raise ValueError("Codex MCP tools must map names to policy objects")
879
+ if set(policy) != {"approval_mode"}:
880
+ raise ValueError(
881
+ f"Codex MCP tool '{name}' supports only the approval_mode field"
882
+ )
883
+ _require_choice(policy, "approval_mode", _CODEX_APPROVAL_MODES)
305
884
 
306
885
 
307
886
  def _format_toml_value(value) -> str:
@@ -313,11 +892,17 @@ def _format_toml_value(value) -> str:
313
892
  return json.dumps(value)
314
893
  if isinstance(value, list):
315
894
  return "[" + ", ".join(_format_toml_value(v) for v in value) + "]"
895
+ if isinstance(value, dict):
896
+ values = ", ".join(
897
+ f"{_format_toml_key(str(key))} = {_format_toml_value(item)}"
898
+ for key, item in value.items()
899
+ )
900
+ return "{ " + values + " }"
316
901
  raise TypeError(f"Unsupported TOML value: {value!r}")
317
902
 
318
903
 
319
904
  def _format_toml_key(key: str) -> str:
320
- if key.replace("-", "").replace("_", "").isalnum():
905
+ if _TOML_BARE_KEY_RE.fullmatch(key):
321
906
  return key
322
907
  return json.dumps(key)
323
908
 
@@ -339,9 +924,278 @@ def _render_toml_table(prefix: str, data: dict, lines: list[str]) -> None:
339
924
  _render_toml_table(child_prefix, value, lines)
340
925
 
341
926
 
342
- def _write_toml_file(path: Path, data: dict) -> None:
343
- path.parent.mkdir(parents=True, exist_ok=True)
927
+ def _render_managed_codex_block(servers: dict[str, dict]) -> str:
344
928
  lines: list[str] = []
345
- _render_toml_table("", data, lines)
346
- text = "\n".join(lines).rstrip() + "\n"
347
- path.write_text(text, encoding="utf-8")
929
+ lines.append(CODEX_MCP_BLOCK_START)
930
+ for name in sorted(servers):
931
+ if len(lines) > 1 and lines[-1] != "":
932
+ lines.append("")
933
+ prefix = f"mcp_servers.{_format_toml_key(name)}"
934
+ _render_toml_table(prefix, servers[name], lines)
935
+ lines.append(CODEX_MCP_BLOCK_END)
936
+ return "\n".join(lines) + "\n"
937
+
938
+
939
+ def _compose_codex_toml(base: str, managed: dict[str, dict]) -> str:
940
+ if not managed:
941
+ return base
942
+ if not base:
943
+ return _render_managed_codex_block(managed)
944
+ prefix = base
945
+ if not prefix.endswith("\n"):
946
+ prefix += "\n"
947
+ if not prefix.endswith("\n\n"):
948
+ prefix += "\n"
949
+ return prefix + _render_managed_codex_block(managed)
950
+
951
+
952
+ def _split_codex_managed_block(text: str, path: Path) -> tuple[str, dict[str, dict]]:
953
+ lines = text.splitlines(keepends=True)
954
+ starts = [
955
+ i for i, line in enumerate(lines) if line.strip() == CODEX_MCP_BLOCK_START
956
+ ]
957
+ ends = [i for i, line in enumerate(lines) if line.strip() == CODEX_MCP_BLOCK_END]
958
+ if not starts and not ends:
959
+ return text, {}
960
+ if len(starts) != 1 or len(ends) != 1 or starts[0] >= ends[0]:
961
+ raise ValueError(f"{path} has malformed ai-toolkit Codex MCP markers")
962
+
963
+ start, end = starts[0], ends[0]
964
+ block_text = "".join(lines[start + 1 : end])
965
+ block_data = _parse_toml(block_text, path) if block_text.strip() else {}
966
+ if set(block_data) - {"mcp_servers"}:
967
+ raise ValueError(f"{path} has unexpected data inside the managed MCP block")
968
+ bucket = block_data.get("mcp_servers", {})
969
+ if not isinstance(bucket, dict):
970
+ raise ValueError(f"{path} has invalid managed mcp_servers data")
971
+ managed = {
972
+ _validate_server_name(name): _normalize_toml_server(server)
973
+ for name, server in bucket.items()
974
+ }
975
+ base = "".join(lines[:start] + lines[end + 1 :])
976
+ _parse_toml(base, path)
977
+ return base, managed
978
+
979
+
980
+ def _remove_selected_toml_server_tables(
981
+ text: str,
982
+ server_names: set[str],
983
+ path: Path,
984
+ ) -> str:
985
+ if not server_names:
986
+ return text
987
+ lines = text.splitlines(keepends=True)
988
+ headers = _toml_table_headers(lines)
989
+ remove_lines: set[int] = set()
990
+ for position, (line_index, table_path) in enumerate(headers):
991
+ next_index = (
992
+ headers[position + 1][0] if position + 1 < len(headers) else len(lines)
993
+ )
994
+ if (
995
+ len(table_path) >= 2
996
+ and table_path[0] == "mcp_servers"
997
+ and table_path[1] in server_names
998
+ ):
999
+ remove_lines.update(range(line_index, next_index))
1000
+
1001
+ rendered = "".join(
1002
+ line for index, line in enumerate(lines) if index not in remove_lines
1003
+ )
1004
+ data = _parse_toml(rendered, path)
1005
+ bucket = data.get("mcp_servers", {})
1006
+ if not isinstance(bucket, dict):
1007
+ raise ValueError(f"{path} has invalid mcp_servers data")
1008
+ remaining = server_names & set(bucket)
1009
+ if remaining:
1010
+ names = ", ".join(sorted(remaining))
1011
+ raise ValueError(
1012
+ f"Cannot safely replace inline/dotted Codex MCP server definition(s): {names}"
1013
+ )
1014
+ return rendered
1015
+
1016
+
1017
+ def _toml_table_headers(lines: list[str]) -> list[tuple[int, tuple[str, ...]]]:
1018
+ headers: list[tuple[int, tuple[str, ...]]] = []
1019
+ multiline: str | None = None
1020
+ for index, line in enumerate(lines):
1021
+ if multiline is None:
1022
+ table_path = _toml_table_header_path(line)
1023
+ if table_path is not None:
1024
+ headers.append((index, table_path))
1025
+ multiline = _toml_multiline_state(line, multiline)
1026
+ return headers
1027
+
1028
+
1029
+ def _toml_table_header_path(line: str) -> tuple[str, ...] | None:
1030
+ stripped = line.strip()
1031
+ if not stripped.startswith("[") or stripped.startswith("[["):
1032
+ return None
1033
+ if tomllib is None: # pragma: no cover
1034
+ raise RuntimeError("tomllib is unavailable")
1035
+ probe = "__ai_toolkit_table_probe__"
1036
+ try:
1037
+ parsed = tomllib.loads(f"{stripped}\n{probe} = true\n")
1038
+ except tomllib.TOMLDecodeError:
1039
+ return None
1040
+ found = _find_toml_probe_path(parsed, probe, ())
1041
+ return found
1042
+
1043
+
1044
+ def _find_toml_probe_path(
1045
+ data: dict, probe: str, path: tuple[str, ...]
1046
+ ) -> tuple[str, ...] | None:
1047
+ if data.get(probe) is True:
1048
+ return path
1049
+ for key, value in data.items():
1050
+ if isinstance(value, dict):
1051
+ found = _find_toml_probe_path(value, probe, path + (key,))
1052
+ if found is not None:
1053
+ return found
1054
+ return None
1055
+
1056
+
1057
+ def _toml_multiline_state(line: str, state: str | None) -> str | None:
1058
+ index = 0
1059
+ while index < len(line):
1060
+ if state == "basic":
1061
+ if line.startswith('"""', index) and not _is_escaped(line, index):
1062
+ state = None
1063
+ index += 3
1064
+ else:
1065
+ index += 1
1066
+ continue
1067
+ if state == "literal":
1068
+ if line.startswith("'''", index):
1069
+ state = None
1070
+ index += 3
1071
+ else:
1072
+ index += 1
1073
+ continue
1074
+
1075
+ if line[index] == "#":
1076
+ break
1077
+ if line.startswith('"""', index):
1078
+ state = "basic"
1079
+ index += 3
1080
+ continue
1081
+ if line.startswith("'''", index):
1082
+ state = "literal"
1083
+ index += 3
1084
+ continue
1085
+ if line[index] == '"':
1086
+ index = _skip_single_line_basic_string(line, index + 1)
1087
+ continue
1088
+ if line[index] == "'":
1089
+ closing = line.find("'", index + 1)
1090
+ index = len(line) if closing < 0 else closing + 1
1091
+ continue
1092
+ index += 1
1093
+ return state
1094
+
1095
+
1096
+ def _skip_single_line_basic_string(line: str, index: int) -> int:
1097
+ while index < len(line):
1098
+ if line[index] == '"' and not _is_escaped(line, index):
1099
+ return index + 1
1100
+ index += 1
1101
+ return index
1102
+
1103
+
1104
+ def _is_escaped(text: str, index: int) -> bool:
1105
+ backslashes = 0
1106
+ index -= 1
1107
+ while index >= 0 and text[index] == "\\":
1108
+ backslashes += 1
1109
+ index -= 1
1110
+ return backslashes % 2 == 1
1111
+
1112
+
1113
+ def _load_toml_document(path: Path) -> tuple[bytes | None, str]:
1114
+ original = _read_optional_file(path)
1115
+ if original is None:
1116
+ return None, ""
1117
+ try:
1118
+ text = original.decode("utf-8")
1119
+ except UnicodeDecodeError as error:
1120
+ raise ValueError(f"{path} is not valid UTF-8 TOML: {error}") from error
1121
+ _parse_toml(text, path)
1122
+ return original, text
1123
+
1124
+
1125
+ def _read_optional_file(path: Path) -> bytes | None:
1126
+ _assert_safe_config_path(path)
1127
+ if not path.exists():
1128
+ return None
1129
+ if not path.is_file():
1130
+ raise RuntimeError(f"MCP config destination is not a file: {path}")
1131
+ return path.read_bytes()
1132
+
1133
+
1134
+ def _parse_toml(text: str, path: Path) -> dict:
1135
+ if not text.strip():
1136
+ return {}
1137
+ if tomllib is None: # pragma: no cover
1138
+ raise RuntimeError("tomllib is unavailable")
1139
+ try:
1140
+ data = tomllib.loads(text)
1141
+ except tomllib.TOMLDecodeError as error:
1142
+ raise ValueError(f"{path} contains invalid TOML: {error}") from error
1143
+ if not isinstance(data, dict): # pragma: no cover - tomllib always returns dict
1144
+ raise ValueError(f"{path} must contain a TOML document")
1145
+ return data
1146
+
1147
+
1148
+ def _assert_safe_config_path(path: Path) -> None:
1149
+ # The config itself, its editor directory, and the caller-provided project
1150
+ # or HOME root must not redirect writes through symlinks. Checking the
1151
+ # grandparent is necessary for layouts such as `.codex/config.toml`.
1152
+ for candidate in (path, path.parent, path.parent.parent):
1153
+ if candidate.is_symlink():
1154
+ raise RuntimeError(f"Refusing symlinked MCP config path: {candidate}")
1155
+
1156
+
1157
+ def _atomic_write_bytes(path: Path, content: bytes) -> None:
1158
+ _assert_safe_config_path(path)
1159
+ path.parent.mkdir(parents=True, exist_ok=True)
1160
+ _assert_safe_config_path(path)
1161
+ mode = path.stat().st_mode & 0o777 if path.exists() and path.is_file() else None
1162
+ fd, temp_name = tempfile.mkstemp(
1163
+ dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
1164
+ )
1165
+ temp_path = Path(temp_name)
1166
+ try:
1167
+ if mode is not None:
1168
+ os.fchmod(fd, mode)
1169
+ with os.fdopen(fd, "wb") as handle:
1170
+ fd = -1
1171
+ handle.write(content)
1172
+ handle.flush()
1173
+ os.fsync(handle.fileno())
1174
+ _assert_safe_config_path(path)
1175
+ os.replace(temp_path, path)
1176
+ _fsync_directory(path.parent)
1177
+ except Exception:
1178
+ if fd >= 0:
1179
+ os.close(fd)
1180
+ temp_path.unlink(missing_ok=True)
1181
+ raise
1182
+
1183
+
1184
+ def _fsync_directory(path: Path) -> None:
1185
+ if os.name == "nt":
1186
+ return
1187
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
1188
+ try:
1189
+ fd = os.open(path, flags)
1190
+ except OSError as error:
1191
+ if error.errno in _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS:
1192
+ return
1193
+ raise
1194
+ try:
1195
+ try:
1196
+ os.fsync(fd)
1197
+ except OSError as error:
1198
+ if error.errno not in _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS:
1199
+ raise
1200
+ finally:
1201
+ os.close(fd)