netizen-cli 0.10.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 (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,536 @@
1
+ """Instance-owned data and startup migration; never a package installer.
2
+
3
+ Only explicit setup initializes a database. Every actual service start validates
4
+ and, when necessary, migrates it while retaining the same lifetime lock until
5
+ exit. Migration backups are recovery material, not automatic rollback commands.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Iterator
11
+ from contextlib import closing, contextmanager
12
+ import fcntl
13
+ import os
14
+ from pathlib import Path
15
+ import pwd
16
+ import sqlite3
17
+ import stat
18
+ import tempfile
19
+ import time
20
+ import uuid
21
+
22
+ import yaml
23
+
24
+ from .bindings import BindingStore
25
+ from .database_migrations import (
26
+ MigrationPlan,
27
+ migrate_channel_database,
28
+ plan_channel_database,
29
+ )
30
+ from .deployment.update_protocol import install_lock
31
+ from .instance import (
32
+ INSTANCE_ROOT_MARKER,
33
+ INSTANCE_ROOT_MARKER_CONTENT,
34
+ require_instance_root_marker,
35
+ resolve_instance_root,
36
+ )
37
+ from .lark_app import load_lark_app
38
+
39
+
40
+ INSTANCE_DATA_MARKER = ".netizen-initialized"
41
+ _DATA_STATES = {
42
+ state: f"netizen-instance-data-v1:{state}\n".encode()
43
+ for state in ("initializing", "initialized", "purging", "purged")
44
+ }
45
+ _BACKUP_MARKER = ".netizen-migration-backup"
46
+ _BACKUP_MARKER_CONTENT = b"netizen-channel-migration-backup-v1\n"
47
+ _DIRECTORIES = ("state", "credentials", "lark-app")
48
+ _PURGE_FILES = (
49
+ "config.yaml", "lark-app/config.json", "credentials/admin-web-secret",
50
+ "state/channel.sqlite3", "state/channel.sqlite3-wal", "state/channel.sqlite3-shm",
51
+ "state/channel.sqlite3-journal", "state/netizen.log", "state/netizen.log.1",
52
+ "state/netizen.log.2", "state/launchd.stderr.log", "state/service.ready",
53
+ "state/service.identity.json",
54
+ )
55
+
56
+
57
+ class InstanceDataError(RuntimeError):
58
+ """The instance cannot be safely initialized, started, or purged."""
59
+
60
+
61
+ class StartupRejected(InstanceDataError):
62
+ """Deterministic startup rejection that requires configuration/data repair.
63
+
64
+ Managed service entry points exit cleanly without readiness for this error,
65
+ so the manager does not repeatedly retry an unchanged invalid instance.
66
+ """
67
+
68
+
69
+ def _directory(path: Path) -> None:
70
+ info = path.lstat()
71
+ if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
72
+ or stat.S_IMODE(info.st_mode) & 0o022):
73
+ raise InstanceDataError(f"unsafe instance directory: {path}")
74
+
75
+
76
+ def _file(path: Path, *, private: bool = True) -> None:
77
+ info = path.lstat()
78
+ if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid()
79
+ or info.st_nlink != 1
80
+ or (private and stat.S_IMODE(info.st_mode) != 0o600)):
81
+ raise InstanceDataError(f"unsafe instance file: {path}")
82
+
83
+
84
+ def _root(root: Path) -> Path:
85
+ canonical = resolve_instance_root(root)
86
+ if Path(root) != canonical:
87
+ raise InstanceDataError(f"instance operation requires canonical root: {canonical}")
88
+ _directory(canonical)
89
+ try:
90
+ require_instance_root_marker(canonical)
91
+ except (OSError, ValueError) as error:
92
+ raise InstanceDataError(f"instance ownership is not confirmed: {error}") from error
93
+ return canonical
94
+
95
+
96
+ def _sync(directory: Path) -> None:
97
+ descriptor = os.open(directory, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
98
+ try:
99
+ os.fsync(descriptor)
100
+ finally:
101
+ os.close(descriptor)
102
+
103
+
104
+ def _atomic_write(path: Path, payload: bytes) -> None:
105
+ try:
106
+ _file(path)
107
+ except FileNotFoundError:
108
+ pass
109
+ descriptor, temporary = tempfile.mkstemp(prefix=f".{path.name}-", dir=path.parent)
110
+ try:
111
+ with os.fdopen(descriptor, "wb") as output:
112
+ output.write(payload)
113
+ output.flush()
114
+ os.fsync(output.fileno())
115
+ os.replace(temporary, path)
116
+ _sync(path.parent)
117
+ finally:
118
+ Path(temporary).unlink(missing_ok=True)
119
+
120
+
121
+ def ensure_instance_root(root: Path) -> None:
122
+ """Explicit setup's minimal ownership preparation, before any other writes.
123
+
124
+ A preconfigured private config/profile/secret may be supplied. Unmarked state
125
+ and unknown files in managed directories are never silently claimed.
126
+ """
127
+ canonical = resolve_instance_root(root)
128
+ if Path(root) != canonical:
129
+ raise InstanceDataError(f"instance operation requires canonical root: {canonical}")
130
+ root.mkdir(mode=0o700, parents=True, exist_ok=True)
131
+ _directory(root)
132
+ marker = root / INSTANCE_ROOT_MARKER
133
+ if marker.exists() or marker.is_symlink():
134
+ _root(root)
135
+ else:
136
+ for name in _DIRECTORIES:
137
+ directory = root / name
138
+ if directory.exists() or directory.is_symlink():
139
+ _directory(directory)
140
+ allowed = {"credentials": {"admin-web-secret"}, "lark-app": {"config.json"}}.get(name, set())
141
+ for child in directory.iterdir():
142
+ if child.name not in allowed:
143
+ raise InstanceDataError(f"unowned instance data; refusing to claim {child}")
144
+ _file(child)
145
+ for name in ("config.yaml", INSTANCE_DATA_MARKER):
146
+ path = root / name
147
+ if path.exists() or path.is_symlink():
148
+ if name == INSTANCE_DATA_MARKER:
149
+ raise InstanceDataError(f"data marker exists without root ownership: {path}")
150
+ _file(path)
151
+ profile = root / "lark-app" / "config.json"
152
+ if profile.exists():
153
+ load_lark_app(profile, allow_incomplete=True)
154
+ secret = root / "credentials" / "admin-web-secret"
155
+ if secret.exists():
156
+ from .admin.auth import load_credential_snapshot
157
+ load_credential_snapshot(secret)
158
+ descriptor, temporary = tempfile.mkstemp(prefix=".netizen-root-", dir=root)
159
+ try:
160
+ with os.fdopen(descriptor, "wb") as output:
161
+ output.write(INSTANCE_ROOT_MARKER_CONTENT)
162
+ output.flush()
163
+ os.fsync(output.fileno())
164
+ # Publish complete ownership evidence without replacing a concurrent
165
+ # claim; an in-progress marker must never look like a damaged root.
166
+ try:
167
+ os.link(temporary, marker)
168
+ except FileExistsError:
169
+ pass
170
+ _root(root)
171
+ finally:
172
+ Path(temporary).unlink(missing_ok=True)
173
+ _sync(root)
174
+ for name in _DIRECTORIES:
175
+ directory = root / name
176
+ directory.mkdir(mode=0o700, exist_ok=True)
177
+ _directory(directory)
178
+
179
+
180
+ def acquire_lifetime_lock(root: Path) -> int:
181
+ """Acquire the exclusive service lock without creating an instance."""
182
+ _root(root)
183
+ directory = root / "state"
184
+ _directory(directory)
185
+ descriptor = os.open(directory / "service.lifetime.lock", os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
186
+ try:
187
+ _file(directory / "service.lifetime.lock")
188
+ os.set_inheritable(descriptor, False)
189
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
190
+ return descriptor
191
+ except BaseException:
192
+ os.close(descriptor)
193
+ raise
194
+
195
+
196
+ @contextmanager
197
+ def instance_lifetime_lock(root: Path) -> Iterator[int]:
198
+ descriptor = acquire_lifetime_lock(root)
199
+ try:
200
+ yield descriptor
201
+ finally:
202
+ os.close(descriptor)
203
+
204
+
205
+ @contextmanager
206
+ def root_maintenance_lock(root: Path) -> Iterator[int]:
207
+ """Serialize registration/removal/setup; do not hold the service's own lock."""
208
+ _root(root)
209
+ with install_lock(root) as descriptor:
210
+ yield descriptor
211
+
212
+
213
+ def validate_lifetime_lock(root: Path, descriptor: int) -> None:
214
+ """Require the exact already-held open-file description, not just a busy file."""
215
+ _root(root)
216
+ path = root / "state" / "service.lifetime.lock"
217
+ _directory(path.parent)
218
+ _file(path)
219
+ actual, expected = os.fstat(descriptor), path.lstat()
220
+ if (not stat.S_ISREG(actual.st_mode)
221
+ or (actual.st_dev, actual.st_ino) != (expected.st_dev, expected.st_ino)):
222
+ raise InstanceDataError("lifetime lock descriptor does not match this instance")
223
+ os.set_inheritable(descriptor, False)
224
+ probe = os.open(path, os.O_RDWR | os.O_NOFOLLOW)
225
+ try:
226
+ try:
227
+ fcntl.flock(probe, fcntl.LOCK_EX | fcntl.LOCK_NB)
228
+ except BlockingIOError:
229
+ pass
230
+ else:
231
+ raise InstanceDataError("instance lifetime lock is not held")
232
+ finally:
233
+ os.close(probe)
234
+ try:
235
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
236
+ except BlockingIOError as error:
237
+ raise InstanceDataError("lifetime lock belongs to another process") from error
238
+
239
+
240
+ def _data_state(root: Path) -> str | None:
241
+ marker = root / INSTANCE_DATA_MARKER
242
+ try:
243
+ _file(marker)
244
+ except FileNotFoundError:
245
+ return None
246
+ payload = marker.read_bytes()
247
+ for state, expected in _DATA_STATES.items():
248
+ if payload == expected:
249
+ return state
250
+ raise InstanceDataError(f"unrecognized instance data marker: {marker}")
251
+
252
+
253
+ def _database(root: Path) -> Path:
254
+ _directory(root / "state")
255
+ path = root / "state" / "channel.sqlite3"
256
+ try:
257
+ _file(path)
258
+ except FileNotFoundError as error:
259
+ raise InstanceDataError("prepared instance database is missing; restore its data, do not recreate an empty database") from error
260
+ for suffix in ("-wal", "-shm", "-journal"):
261
+ try:
262
+ _file(Path(str(path) + suffix))
263
+ except FileNotFoundError:
264
+ pass
265
+ return path
266
+
267
+
268
+ def _check_recovery(root: Path) -> None:
269
+ intent = root / "state" / ".activation-intent.json"
270
+ if intent.exists() or intent.is_symlink():
271
+ raise InstanceDataError("unfinished legacy activation recovery; resolve it manually before CLI startup")
272
+
273
+
274
+ def begin_instance_setup(root: Path, *, lifetime_descriptor: int) -> MigrationPlan | None:
275
+ """Record explicit setup before configuration or authorization can write data.
276
+
277
+ Return a validated existing database plan, or None for a proven new setup.
278
+ This never creates a database or reclassifies a lost one as a new instance.
279
+ """
280
+ validate_lifetime_lock(root, lifetime_descriptor)
281
+ _check_recovery(root)
282
+ state = _data_state(root)
283
+ database = root / "state" / "channel.sqlite3"
284
+ has_data = any(
285
+ candidate.exists() or candidate.is_symlink()
286
+ for suffix in ("", "-wal", "-shm", "-journal")
287
+ for candidate in (Path(str(database) + suffix),)
288
+ )
289
+ if state == "initialized" or (state == "initializing" and has_data):
290
+ return plan_channel_database(_database(root))
291
+ if state == "purging":
292
+ raise InstanceDataError("instance purge is incomplete; retry remove --purge before setup")
293
+ for suffix in ("", "-wal", "-shm", "-journal"):
294
+ candidate = Path(str(database) + suffix)
295
+ if candidate.exists() or candidate.is_symlink():
296
+ raise InstanceDataError(f"uninitialized instance has existing database data: {candidate}; explicit manual repair is required")
297
+ if state != "initializing":
298
+ _atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["initializing"])
299
+ return None
300
+
301
+
302
+ def initialize_instance_data(root: Path, *, lifetime_descriptor: int) -> MigrationPlan:
303
+ """Explicit setup only; a missing DB in an existing instance is never new."""
304
+ plan = begin_instance_setup(root, lifetime_descriptor=lifetime_descriptor)
305
+ if plan is not None:
306
+ if _data_state(root) == "initializing":
307
+ _atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["initialized"])
308
+ return plan
309
+ database = root / "state" / "channel.sqlite3"
310
+ # The durable initializing state proves this is unfinished explicit setup,
311
+ # unlike an initialized instance that lost its DB. An interruption before
312
+ # file creation may retry; any partial existing DB still needs validation.
313
+ descriptor = os.open(database, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
314
+ os.close(descriptor)
315
+ try:
316
+ store = BindingStore(database)
317
+ store.close()
318
+ except sqlite3.Error as error:
319
+ raise InstanceDataError(
320
+ "database initialization failed; partial data was preserved; inspect and repair the instance before retrying setup"
321
+ ) from error
322
+ plan = plan_channel_database(database)
323
+ _atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["initialized"])
324
+ return plan
325
+
326
+
327
+ def validate_prepared_instance(root: Path) -> MigrationPlan:
328
+ """Read-only validation; old supported schemas remain eligible for startup."""
329
+ _root(root)
330
+ _check_recovery(root)
331
+ if _data_state(root) != "initialized":
332
+ raise InstanceDataError("instance is not initialized; run netizen setup explicitly (or repair interrupted setup/purge)")
333
+ _file(root / "config.yaml")
334
+ return plan_channel_database(_database(root))
335
+
336
+
337
+ def _backup(root: Path, database: Path) -> Path:
338
+ directory = root / "state" / "migration-backups"
339
+ directory.mkdir(mode=0o700, exist_ok=True)
340
+ _directory(directory)
341
+ backup_dir = directory / uuid.uuid4().hex
342
+ backup_dir.mkdir(mode=0o700)
343
+ destination = backup_dir / "channel.sqlite3"
344
+ descriptor = os.open(destination, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
345
+ os.close(descriptor)
346
+ source = sqlite3.connect(database.as_uri() + "?mode=ro", uri=True)
347
+ deadline = time.monotonic() + 30
348
+ def progress(_status: int, _remaining: int, _total: int) -> None:
349
+ if time.monotonic() >= deadline:
350
+ raise TimeoutError("database backup timed out; check for an external database writer")
351
+ try:
352
+ target = sqlite3.connect(destination)
353
+ try:
354
+ source.backup(target, pages=256, progress=progress)
355
+ finally:
356
+ target.close()
357
+ finally:
358
+ source.close()
359
+ descriptor = os.open(destination, os.O_RDONLY | os.O_NOFOLLOW)
360
+ try:
361
+ os.fsync(descriptor)
362
+ finally:
363
+ os.close(descriptor)
364
+ _atomic_write(backup_dir / _BACKUP_MARKER, _BACKUP_MARKER_CONTENT)
365
+ _sync(directory)
366
+ return destination
367
+
368
+
369
+ def prepare_instance(root: Path, *, lifetime_descriptor: int) -> MigrationPlan:
370
+ """Actual-start admission, with caller retaining its lock until service exit."""
371
+ try:
372
+ return _prepare_instance(root, lifetime_descriptor=lifetime_descriptor)
373
+ except (RuntimeError, ValueError, FileNotFoundError, PermissionError) as error:
374
+ # SQLite's public error codes distinguish a persistent invalid schema
375
+ # from transient contention/storage failures; never parse its messages.
376
+ cause: BaseException | None = error
377
+ while cause is not None:
378
+ if isinstance(cause, sqlite3.Error) and (
379
+ getattr(cause, "sqlite_errorcode", 0) & 0xff
380
+ ) in {
381
+ sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED, sqlite3.SQLITE_IOERR,
382
+ sqlite3.SQLITE_FULL, sqlite3.SQLITE_NOMEM, sqlite3.SQLITE_INTERRUPT,
383
+ sqlite3.SQLITE_CANTOPEN, sqlite3.SQLITE_READONLY,
384
+ }:
385
+ raise
386
+ cause = cause.__cause__
387
+ raise StartupRejected(str(error)) from error
388
+
389
+
390
+ def _prepare_instance(root: Path, *, lifetime_descriptor: int) -> MigrationPlan:
391
+ validate_lifetime_lock(root, lifetime_descriptor)
392
+ plan = validate_prepared_instance(root)
393
+ if plan["steps"]:
394
+ database = _database(root)
395
+ _backup(root, database)
396
+ migrate_channel_database(database, expected_source_version=plan["source_version"])
397
+ else:
398
+ return plan
399
+ # Validate the committed target. Failure never restores an older snapshot.
400
+ return plan_channel_database(_database(root))
401
+
402
+
403
+ def _protected_paths(root: Path, *, protected_codex_home: Path | None = None) -> tuple[Path, ...]:
404
+ home = Path(pwd.getpwuid(os.geteuid()).pw_dir)
405
+ paths = [Path(os.environ.get("CODEX_HOME", str(home / ".codex"))).expanduser().resolve()]
406
+ if protected_codex_home is not None:
407
+ if not protected_codex_home.is_absolute():
408
+ raise InstanceDataError("bound service CODEX_HOME must be an absolute path")
409
+ paths.append(protected_codex_home.resolve())
410
+ config = root / "config.yaml"
411
+ if config.exists() or config.is_symlink():
412
+ _file(config)
413
+ try:
414
+ value = yaml.safe_load(config.read_text(encoding="utf-8"))
415
+ if not isinstance(value, dict):
416
+ raise ValueError("configuration root must be a mapping")
417
+ instance = value.get("instance", {})
418
+ projects = value.get("projects", {})
419
+ if not isinstance(instance, dict) or not isinstance(projects, dict):
420
+ raise ValueError("invalid Project configuration")
421
+ for raw in [instance.get("projectRoot"), *projects.values()]:
422
+ if raw is None:
423
+ continue
424
+ if not isinstance(raw, str) or not Path(raw).expanduser().is_absolute():
425
+ raise ValueError("Project paths must be absolute")
426
+ paths.append(Path(raw).expanduser().resolve())
427
+ except (ValueError, OSError, yaml.YAMLError) as error:
428
+ raise InstanceDataError(f"cannot verify protected Project paths before purge: {error}") from error
429
+ elif _data_state(root) not in {"initializing", "purged"}:
430
+ raise InstanceDataError(
431
+ "cannot verify Project paths without instance configuration; preserve remaining files. "
432
+ "Review the previous deletion report and backups for manual recovery or restore the missing configuration. "
433
+ "Do not delete the whole root or alter ownership markers to bypass validation."
434
+ )
435
+ database = root / "state" / "channel.sqlite3"
436
+ if database.exists() or database.is_symlink():
437
+ try:
438
+ # Admin-registered Projects need not appear among YAML seed aliases.
439
+ # Read the supported persisted format without creating a Store,
440
+ # migrating it, or opening a writer in the controlling environment.
441
+ plan_channel_database(_database(root))
442
+ with closing(sqlite3.connect(database.as_uri() + "?mode=ro", uri=True, timeout=0.25)) as connection:
443
+ connection.execute("PRAGMA query_only=ON")
444
+ for (raw,) in connection.execute("SELECT cwd FROM projects"):
445
+ if not isinstance(raw, str) or not Path(raw).is_absolute():
446
+ raise ValueError("persisted Project path is not absolute")
447
+ paths.append(Path(raw).resolve())
448
+ except (OSError, RuntimeError, ValueError, sqlite3.Error) as error:
449
+ raise InstanceDataError("cannot verify persisted Project paths before purge; preserve data and inspect or repair the database") from error
450
+ elif _data_state(root) not in {"initializing", "purged"}:
451
+ # Once deletion has removed that evidence, a partial retry cannot infer
452
+ # the original Project inventory. Fail closed instead of claiming that
453
+ # missing data means the instance never had a registered Project.
454
+ raise InstanceDataError(
455
+ "cannot verify persisted Project paths without the database; preserve remaining files. "
456
+ "Review the previous deletion report and backups for manual recovery or restore the missing database. "
457
+ "Do not delete the whole root or alter ownership markers to bypass validation."
458
+ )
459
+ return tuple(paths)
460
+
461
+
462
+ def purge_inventory(root: Path, *, protected_codex_home: Path | None = None) -> tuple[Path, ...]:
463
+ """An explicit finite file inventory, never recursive ownership by location."""
464
+ _root(root)
465
+ if _data_state(root) not in {"initialized", "initializing", "purging", "purged"}:
466
+ raise InstanceDataError("instance data ownership is not established; refusing purge")
467
+ for name in _DIRECTORIES:
468
+ directory = root / name
469
+ if directory.exists() or directory.is_symlink():
470
+ _directory(directory)
471
+ protected = _protected_paths(root, protected_codex_home=protected_codex_home)
472
+ paths = []
473
+ for name in _PURGE_FILES:
474
+ candidate = root / name
475
+ try:
476
+ _file(candidate)
477
+ except FileNotFoundError:
478
+ continue
479
+ paths.append(candidate)
480
+ backups = root / "state" / "migration-backups"
481
+ if backups.exists() or backups.is_symlink():
482
+ _directory(backups)
483
+ for child in sorted(backups.iterdir()):
484
+ # Other files/directories remain user-owned, even in this namespace.
485
+ if len(child.name) != 32 or any(c not in "0123456789abcdef" for c in child.name):
486
+ continue
487
+ _directory(child)
488
+ marker = child / _BACKUP_MARKER
489
+ if not marker.exists() and not marker.is_symlink():
490
+ continue
491
+ _file(marker)
492
+ if marker.read_bytes() != _BACKUP_MARKER_CONTENT:
493
+ raise InstanceDataError(f"unrecognized migration backup marker: {marker}")
494
+ database = child / "channel.sqlite3"
495
+ if database.exists() or database.is_symlink():
496
+ _file(database)
497
+ paths.append(database)
498
+ paths.append(marker)
499
+ for candidate in paths:
500
+ if any(candidate == path or candidate.is_relative_to(path) or path.is_relative_to(candidate) for path in protected):
501
+ raise InstanceDataError(f"purge target overlaps Project or shared Codex state: {candidate}")
502
+ # Delete configuration last so a partial purge retry can still check Projects.
503
+ return tuple(sorted(paths, key=lambda path: (
504
+ path == root / "config.yaml", path.name == _BACKUP_MARKER, str(path),
505
+ )))
506
+
507
+
508
+ class InstancePurgeError(InstanceDataError):
509
+ def __init__(self, message: str, deleted: tuple[Path, ...]) -> None:
510
+ super().__init__(message)
511
+ self.deleted = deleted
512
+
513
+
514
+ def purge_instance_data(
515
+ root: Path, *, expected_inventory: tuple[Path, ...], lifetime_descriptor: int,
516
+ protected_codex_home: Path | None = None,
517
+ ) -> tuple[Path, ...]:
518
+ """Caller has confirmed service removal; preserve root, locks and unknowns."""
519
+ validate_lifetime_lock(root, lifetime_descriptor)
520
+ inventory = purge_inventory(root, protected_codex_home=protected_codex_home)
521
+ # Clean shutdown may remove WAL/SHM or readiness files shown at confirmation.
522
+ # A narrower inventory is safe; newly discovered targets need confirmation.
523
+ if not set(inventory).issubset(expected_inventory):
524
+ raise InstanceDataError("purge inventory changed since confirmation; inspect and confirm again")
525
+ _atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["purging"])
526
+ deleted: list[Path] = []
527
+ try:
528
+ for path in inventory:
529
+ _file(path)
530
+ path.unlink()
531
+ deleted.append(path)
532
+ _sync(path.parent)
533
+ _atomic_write(root / INSTANCE_DATA_MARKER, _DATA_STATES["purged"])
534
+ except (OSError, InstanceDataError) as error:
535
+ raise InstancePurgeError(f"instance purge stopped after {len(deleted)} files: {error}", tuple(deleted)) from error
536
+ return tuple(deleted)