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
netizen_cli/main.py ADDED
@@ -0,0 +1,903 @@
1
+ """Single-process Feishu Channel + one long-lived AsyncCodex service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ # The launcher makes this one descriptor inheritable only for its final exec.
8
+ # Restore CLOEXEC before importing SDK modules so import-time subprocesses could
9
+ # never inherit the service-lifetime lock.
10
+ _EARLY_LIFETIME_DESCRIPTOR = os.environ.get("NETIZEN_LIFETIME_LOCK_FD", "")
11
+ if _EARLY_LIFETIME_DESCRIPTOR.isdecimal():
12
+ try:
13
+ os.set_inheritable(int(_EARLY_LIFETIME_DESCRIPTOR), False)
14
+ except OSError:
15
+ # _adopt_lifetime_lock() reports the authoritative validation error.
16
+ pass
17
+ del _EARLY_LIFETIME_DESCRIPTOR
18
+
19
+ import time
20
+
21
+ _IMPORT_STARTED_AT = time.monotonic()
22
+
23
+ import asyncio
24
+ import concurrent.futures
25
+ import contextlib
26
+ import logging
27
+ import signal
28
+ import stat
29
+ import sys
30
+ import uuid
31
+ from collections.abc import Awaitable, Callable
32
+ from logging.handlers import RotatingFileHandler
33
+ from pathlib import Path
34
+ from typing import Any
35
+
36
+ from lark_channel import (
37
+ ChatQueueConfig,
38
+ ChannelConfig,
39
+ Events,
40
+ FeishuChannel,
41
+ InboundConfig,
42
+ LogLevel,
43
+ OutboundConfig,
44
+ PolicyConfig,
45
+ SafetyConfig,
46
+ SecurityConfig,
47
+ TextBatchConfig,
48
+ )
49
+ import lark_oapi as lark
50
+ from openai_codex import AsyncCodex, CodexConfig
51
+
52
+ from .admin.web import AdminWebRunner
53
+ from .admin.auth import CredentialFileError, load_credential_snapshot
54
+ from .bindings import BindingStore, validate_channel_database
55
+ from .builtin_skills import builtin_skill_root
56
+ from .cli_data import (
57
+ InstanceDataError,
58
+ StartupRejected,
59
+ acquire_lifetime_lock,
60
+ prepare_instance,
61
+ )
62
+ from .channel_app import ChannelApplication
63
+ from .codex_runtime import CodexRuntime
64
+ from .instance import resolve_instance_root
65
+ from .management import (
66
+ InstanceManagementService,
67
+ ManagementRuntimePort,
68
+ ScopeCoordinator,
69
+ )
70
+ from .message_history import FeishuMessageHistoryReader
71
+ from .projects import ProjectError, ProjectRegistry
72
+ from .sdk_gap_adapter import (
73
+ AppServerGoalControl,
74
+ AppServerSideBoundaryControl,
75
+ AppServerSkillCatalog,
76
+ AppServerSkillRoots,
77
+ AppServerThreadDeleteControl,
78
+ AppServerThreadSubscriptionControl,
79
+ SdkGapCapabilityUnavailable,
80
+ )
81
+ from .settings import Settings
82
+ from .schedules.mcp import ScheduleMcpRunner
83
+ from .schedules.scheduler import Scheduler
84
+ from .terminal_cleanup import PinnedExperimentalTerminalCleanup
85
+ from .turn_plan_observer import (
86
+ PinnedTurnActivityObserver,
87
+ TurnActivityObservationUnavailable,
88
+ )
89
+
90
+ _IMPORT_SECONDS = time.monotonic() - _IMPORT_STARTED_AT
91
+
92
+
93
+ logger = logging.getLogger(__name__)
94
+
95
+
96
+ _CODEX_SERVICE_CONFIG_OVERRIDES = ("allow_login_shell=false",)
97
+ _SHUTDOWN_BUDGET_SECONDS = 60.0
98
+ _READY_MARKER_CONTENT = b"netizen service ready\n"
99
+ _LOG_MAX_BYTES = 5 * 1024 * 1024
100
+ _LOG_BACKUP_COUNT = 2
101
+
102
+
103
+ def _log_startup_timing(stage: str, started_at: float) -> float:
104
+ now = time.monotonic()
105
+ logger.info("netizen startup: %s completed in %.3fs", stage, now - started_at)
106
+ return now
107
+
108
+
109
+ def _configure_platform_trust() -> None:
110
+ """Use the native macOS trust store for application-owned TLS."""
111
+
112
+ if sys.platform != "darwin":
113
+ return
114
+ import truststore
115
+
116
+ # Netizen is the application entry point, so application-wide injection is
117
+ # intentional. It lets the Channel WebSocket honor Keychain-managed roots
118
+ # without generating a CA bundle or inventing another environment policy.
119
+ truststore.inject_into_ssl()
120
+
121
+
122
+ def build_channel(settings: Settings, store: BindingStore) -> FeishuChannel:
123
+ return FeishuChannel(
124
+ config=ChannelConfig(resolve_sender_names=True),
125
+ app_id=settings.app_id,
126
+ app_secret=settings.app_secret,
127
+ log_level=LogLevel.WARNING,
128
+ policy=PolicyConfig(
129
+ dm_policy="open",
130
+ group_policy="open",
131
+ require_mention=True,
132
+ respond_to_mention_all=False,
133
+ ),
134
+ safety=SafetyConfig(
135
+ text_batch=TextBatchConfig(delay_ms=0, long_delay_ms=0),
136
+ # Scope/Thread serialization belongs to CodexRuntime. The SDK's
137
+ # queue is keyed only by chat_id and would incorrectly serialize
138
+ # independent Feishu topics in the same group.
139
+ chat_queue=ChatQueueConfig(enabled=False, merge_while_busy=False),
140
+ ),
141
+ # ADR 0011's exact-version first-level quote adapter consumes the
142
+ # public InboundMessage.raw relation fields. Keep that contract
143
+ # explicit instead of relying on the SDK's current default.
144
+ inbound=InboundConfig(include_raw=True),
145
+ outbound=OutboundConfig(
146
+ reply_mode="static",
147
+ text_chunk_limit=3_500,
148
+ ),
149
+ security=SecurityConfig(mode=settings.security_mode),
150
+ dedup_store=store,
151
+ )
152
+
153
+
154
+ class ServiceCore:
155
+ """Objects that must live on FeishuChannel's background event loop."""
156
+
157
+ def __init__(
158
+ self,
159
+ *,
160
+ settings: Settings,
161
+ channel: FeishuChannel,
162
+ store: BindingStore,
163
+ projects: ProjectRegistry,
164
+ instance_root: Path,
165
+ ) -> None:
166
+ self._settings = settings
167
+ self._channel = channel
168
+ self._store = store
169
+ self._projects = projects
170
+ self._instance_root = instance_root.resolve()
171
+ self._codex: AsyncCodex | None = None
172
+ self._runtime: CodexRuntime | None = None
173
+ self._management: InstanceManagementService | None = None
174
+ self._message_history_client: Any | None = None
175
+ self._admin: AdminWebRunner | None = None
176
+ self._schedule_mcp: ScheduleMcpRunner | None = None
177
+ self._scheduler: Scheduler | None = None
178
+ self.application: ChannelApplication | None = None
179
+ self._started = False
180
+ self._closed = False
181
+
182
+ async def start(self) -> None:
183
+ stage_started_at = time.monotonic()
184
+ try:
185
+ if self._settings.admin_web.enabled:
186
+ credential_path = self._settings.admin_web.credential_path
187
+ if credential_path is None:
188
+ raise RuntimeError(
189
+ "Admin Web is enabled without a credential path"
190
+ )
191
+ # Credential validation and the closed listener come first so
192
+ # a bad secret or occupied port cannot start the Feishu side.
193
+ self._admin = AdminWebRunner(
194
+ host=self._settings.admin_web.host,
195
+ port=self._settings.admin_web.port,
196
+ credential_path=credential_path,
197
+ instance_root=self._instance_root,
198
+ config_path=self._settings.config_path,
199
+ config_snapshot=self._settings.config_snapshot,
200
+ access_host=self._settings.admin_web.access_host,
201
+ )
202
+ await self._admin.bind()
203
+ stage_started_at = _log_startup_timing("Admin listener", stage_started_at)
204
+
205
+ # Bind the native MCP transport before App Server initializes its
206
+ # catalog. Calls remain closed until the shared application is ready.
207
+ self._schedule_mcp = ScheduleMcpRunner()
208
+ await self._schedule_mcp.bind()
209
+ stage_started_at = _log_startup_timing("Schedule MCP listener", stage_started_at)
210
+
211
+ expired_sides = self._store.expire_live_side_topics()
212
+ if expired_sides:
213
+ logger.info(
214
+ "expired Side Topics from a previous process",
215
+ extra={"count": len(expired_sides)},
216
+ )
217
+ self._codex = AsyncCodex(
218
+ CodexConfig(
219
+ config_overrides=(
220
+ *_CODEX_SERVICE_CONFIG_OVERRIDES,
221
+ *self._schedule_mcp.config_overrides,
222
+ ),
223
+ env={
224
+ **os.environ,
225
+ **self._schedule_mcp.app_server_env,
226
+ "NETIZEN_ROOT": str(self._instance_root),
227
+ },
228
+ )
229
+ )
230
+ await self._codex.__aenter__()
231
+ # Release-local Skills must be active before catalogs, recovery or
232
+ # any native Thread can be reached. Failure closes this startup.
233
+ await AppServerSkillRoots(self._codex).set_roots((builtin_skill_root(),))
234
+ stage_started_at = _log_startup_timing("Codex connection", stage_started_at)
235
+ terminal_cleanup = PinnedExperimentalTerminalCleanup(self._codex)
236
+ thread_subscription_control = AppServerThreadSubscriptionControl(
237
+ self._codex
238
+ )
239
+ skill_catalog = None
240
+ goal_control = None
241
+ side_boundary_control = None
242
+ thread_delete_control = None
243
+ turn_plan_observer = None
244
+ try:
245
+ skill_catalog = AppServerSkillCatalog(self._codex)
246
+ except SdkGapCapabilityUnavailable as error:
247
+ logger.warning("native Skills unavailable: %s", error)
248
+ try:
249
+ goal_control = AppServerGoalControl(self._codex)
250
+ except SdkGapCapabilityUnavailable as error:
251
+ logger.warning("native Goal unavailable: %s", error)
252
+ if callable(getattr(self._codex, "thread_fork", None)):
253
+ try:
254
+ side_boundary_control = AppServerSideBoundaryControl(self._codex)
255
+ except SdkGapCapabilityUnavailable as error:
256
+ logger.warning("native Side unavailable: %s", error)
257
+ else:
258
+ logger.warning("native Side unavailable: AsyncCodex.thread_fork missing")
259
+ try:
260
+ thread_delete_control = AppServerThreadDeleteControl(self._codex)
261
+ except SdkGapCapabilityUnavailable as error:
262
+ logger.warning("native Thread Delete unavailable: %s", error)
263
+ try:
264
+ turn_plan_observer = PinnedTurnActivityObserver(self._codex)
265
+ except TurnActivityObservationUnavailable as error:
266
+ logger.warning("native Turn activity observation unavailable: %s", error)
267
+ self._runtime = CodexRuntime(
268
+ codex=self._codex,
269
+ bindings=self._store,
270
+ terminal_cleanup=terminal_cleanup,
271
+ skill_catalog=skill_catalog,
272
+ goal_control=goal_control,
273
+ side_boundary_control=side_boundary_control,
274
+ thread_subscription_control=thread_subscription_control,
275
+ background_terminal_inspector=terminal_cleanup,
276
+ thread_delete_control=thread_delete_control,
277
+ turn_plan_observer=turn_plan_observer,
278
+ )
279
+ scope_coordinator = ScopeCoordinator()
280
+ self._management = InstanceManagementService(
281
+ bindings=self._store,
282
+ projects=self._projects,
283
+ runtime=ManagementRuntimePort(self._runtime),
284
+ scope_coordinator=scope_coordinator,
285
+ chat_labels=self._channel,
286
+ root=self._instance_root,
287
+ )
288
+ schedules = self._management.enable_schedules(
289
+ app_id=self._settings.app_id, chat_info=self._channel,
290
+ )
291
+ self._management.enable_defaults(
292
+ app_id=self._settings.app_id, chat_info=self._channel,
293
+ )
294
+
295
+ async def manage_schedule(request: dict[str, Any], thread_id: str | None) -> dict[str, Any]:
296
+ return await schedules.manage(request, native_thread_id=thread_id)
297
+
298
+ self._schedule_mcp.attach(manage_schedule)
299
+ if self._admin is not None:
300
+ self._admin.attach_management(self._management)
301
+ self._message_history_client = (
302
+ lark.Client.builder()
303
+ .app_id(self._settings.app_id)
304
+ .app_secret(self._settings.app_secret)
305
+ .timeout(10)
306
+ .log_level(lark.LogLevel.WARNING)
307
+ .build()
308
+ )
309
+ self.application = ChannelApplication(
310
+ app_id=self._settings.app_id,
311
+ channel=self._channel,
312
+ runtime=self._runtime,
313
+ bindings=self._store,
314
+ projects=self._projects,
315
+ message_history=FeishuMessageHistoryReader(
316
+ self._message_history_client
317
+ ),
318
+ management=self._management,
319
+ admin_urls=self._admin.urls if self._admin is not None else (),
320
+ admin_loopback_only=self._admin.loopback_only if self._admin is not None else None,
321
+ instance_root=self._instance_root,
322
+ )
323
+ self._scheduler = Scheduler(
324
+ bindings=self._store, runtime=self._runtime,
325
+ app_id=self._settings.app_id,
326
+ dispatch=self.application.dispatch_scheduled_run,
327
+ )
328
+ schedules.set_wake_handler(self._scheduler.wake)
329
+ schedules.set_refresh_handler(self._scheduler.refresh)
330
+ schedules.set_run_now_handler(self._scheduler.run_now)
331
+ self._management.set_schedule_creation_drain(self._scheduler.drain_project_creation)
332
+ await self._scheduler.recover()
333
+ if expired_sides:
334
+ await self.application.refresh_expired_side_cards(expired_sides)
335
+ self._started = True
336
+ _log_startup_timing("runtime recovery", stage_started_at)
337
+ except BaseException:
338
+ await self._close_partial_start()
339
+ raise
340
+
341
+ def open_admission(self) -> None:
342
+ if not self._started or self.application is None:
343
+ raise RuntimeError("service core is not ready for admission")
344
+ if self._admin is not None:
345
+ self._admin.open_admission()
346
+ if self._schedule_mcp is not None:
347
+ self._schedule_mcp.open_admission()
348
+ if self._scheduler is not None:
349
+ self._scheduler.start()
350
+
351
+ async def _close_partial_start(self) -> None:
352
+ deadline = asyncio.get_running_loop().time() + _SHUTDOWN_BUDGET_SECONDS
353
+ if self._scheduler is not None:
354
+ self._scheduler.close_admission()
355
+ await _cleanup_with_budget(
356
+ "partial Scheduler close", self._scheduler.close,
357
+ deadline=deadline, cap=5,
358
+ )
359
+ await _cleanup_with_budget(
360
+ "partial scheduled dispatch drain", lambda: self._scheduler.drain(deadline),
361
+ deadline=deadline, cap=10,
362
+ )
363
+ if self._schedule_mcp is not None:
364
+ self._schedule_mcp.close_admission()
365
+ await _cleanup_with_budget(
366
+ "partial schedule MCP close", self._schedule_mcp.close,
367
+ deadline=deadline, cap=5,
368
+ )
369
+ if self._admin is not None:
370
+ await _cleanup_with_budget(
371
+ "partial Admin listener close",
372
+ self._admin.close_listener,
373
+ deadline=deadline,
374
+ cap=5,
375
+ )
376
+ await _cleanup_with_budget(
377
+ "partial Admin handler drain",
378
+ lambda: self._admin.drain(deadline),
379
+ deadline=deadline,
380
+ cap=10,
381
+ )
382
+ self._admin.close_auth()
383
+ if self._management is not None:
384
+ await _cleanup_with_budget(
385
+ "partial management I/O close",
386
+ lambda: self._management.close(deadline=deadline),
387
+ deadline=deadline,
388
+ cap=10,
389
+ )
390
+ if self._runtime is not None:
391
+ self._runtime.close_admission()
392
+ await _cleanup_with_budget(
393
+ "partial Runtime task cleanup",
394
+ self._runtime.cancel_tasks,
395
+ deadline=deadline,
396
+ cap=5,
397
+ )
398
+ if self._codex is not None:
399
+ await _cleanup_with_budget(
400
+ "partial Codex transport close",
401
+ self._codex.close,
402
+ deadline=deadline,
403
+ cap=10,
404
+ )
405
+
406
+ async def close(self) -> None:
407
+ if self._closed:
408
+ return
409
+ self._closed = True
410
+ if self._management is not None:
411
+ self._management.set_service_ready(False)
412
+ deadline = asyncio.get_running_loop().time() + _SHUTDOWN_BUDGET_SECONDS
413
+ management_closed = self._management is None
414
+ try:
415
+ if self._scheduler is not None:
416
+ self._scheduler.close_admission()
417
+ if self._schedule_mcp is not None:
418
+ self._schedule_mcp.close_admission()
419
+ if self._admin is not None:
420
+ self._admin.close_admission()
421
+ try:
422
+ self._channel.update_policy(
423
+ dm_policy="disabled",
424
+ group_policy="disabled",
425
+ )
426
+ except Exception:
427
+ logger.exception("failed to disable Feishu admission")
428
+ if self._runtime is not None:
429
+ self._runtime.close_admission()
430
+ if self._scheduler is not None:
431
+ await _cleanup_with_budget(
432
+ "Scheduler timer close", self._scheduler.close,
433
+ deadline=deadline, cap=3,
434
+ )
435
+ if self._admin is not None:
436
+ await _cleanup_with_budget(
437
+ "Admin listener close",
438
+ self._admin.close_listener,
439
+ deadline=deadline,
440
+ cap=5,
441
+ )
442
+ safety = getattr(self._channel, "safety", None)
443
+ if safety is not None:
444
+ await _cleanup_with_budget(
445
+ "Feishu handler drain",
446
+ safety.dispose,
447
+ deadline=deadline,
448
+ cap=10,
449
+ )
450
+ if self._admin is not None:
451
+ await _cleanup_with_budget(
452
+ "Admin handler drain",
453
+ lambda: self._admin.drain(deadline),
454
+ deadline=deadline,
455
+ cap=_SHUTDOWN_BUDGET_SECONDS,
456
+ )
457
+ if self._schedule_mcp is not None:
458
+ await _cleanup_with_budget(
459
+ "schedule MCP handler drain", lambda: self._schedule_mcp.drain(deadline),
460
+ deadline=deadline, cap=10,
461
+ )
462
+ if self._scheduler is not None:
463
+ await _cleanup_with_budget(
464
+ "scheduled dispatch drain", lambda: self._scheduler.drain(deadline),
465
+ deadline=deadline, cap=10,
466
+ )
467
+ if self._management is not None:
468
+ management_closed = await _cleanup_with_budget(
469
+ "management I/O drain",
470
+ lambda: self._management.close(deadline=deadline),
471
+ deadline=deadline,
472
+ cap=15,
473
+ )
474
+ if self._runtime is not None:
475
+ interrupted = await _cleanup_with_budget(
476
+ "native Turn interrupt",
477
+ self._runtime.interrupt_all,
478
+ deadline=deadline,
479
+ cap=15,
480
+ )
481
+ if interrupted:
482
+ remaining = max(
483
+ 0.0,
484
+ min(15.0, deadline - asyncio.get_running_loop().time()),
485
+ )
486
+ idle = remaining > 0 and await self._runtime.wait_idle(
487
+ timeout=remaining
488
+ )
489
+ if not idle:
490
+ logger.warning("native Turns did not drain before SDK close")
491
+ else:
492
+ logger.warning(
493
+ "native Turn cleanup did not complete; skipping completion drain"
494
+ )
495
+ finally:
496
+ try:
497
+ try:
498
+ if self.application is not None:
499
+ await _cleanup_with_budget(
500
+ "Feishu presentation cleanup",
501
+ self.application.close,
502
+ deadline=deadline,
503
+ cap=4,
504
+ )
505
+ finally:
506
+ # A cancelled or timed-out drain leaves submitted I/O running.
507
+ # Give it one final bounded join within the same shutdown budget.
508
+ if not management_closed and self._management is not None:
509
+ await _cleanup_with_budget(
510
+ "management I/O final cleanup",
511
+ lambda: self._management.close(deadline=deadline),
512
+ deadline=deadline,
513
+ cap=4,
514
+ )
515
+ finally:
516
+ try:
517
+ if self._codex is not None:
518
+ await _cleanup_with_budget(
519
+ "Codex transport close",
520
+ self._codex.close,
521
+ deadline=deadline,
522
+ cap=10,
523
+ )
524
+ finally:
525
+ if self._runtime is not None:
526
+ await _cleanup_with_budget(
527
+ "Runtime task cleanup",
528
+ self._runtime.cancel_tasks,
529
+ deadline=deadline,
530
+ cap=5,
531
+ )
532
+ if self._admin is not None:
533
+ self._admin.close_auth()
534
+ if self._schedule_mcp is not None:
535
+ await _cleanup_with_budget(
536
+ "schedule MCP transport close", self._schedule_mcp.close,
537
+ deadline=deadline, cap=5,
538
+ )
539
+ await _cleanup_with_budget(
540
+ "Binding Store close",
541
+ self._store.aclose,
542
+ deadline=deadline,
543
+ cap=5,
544
+ )
545
+
546
+
547
+ async def run(
548
+ settings: Settings,
549
+ *,
550
+ instance_root: Path,
551
+ ready_file: Path | None = None,
552
+ ) -> None:
553
+ stage_started_at = time.monotonic()
554
+ # Initialization belongs to explicit setup. A lost database must never turn
555
+ # into a fresh empty instance merely because Runtime was restarted.
556
+ validate_channel_database(settings.data_dir / "channel.sqlite3")
557
+ store = BindingStore(settings.data_dir / "channel.sqlite3")
558
+ stage_started_at = _log_startup_timing("database", stage_started_at)
559
+ try:
560
+ try:
561
+ projects = ProjectRegistry(
562
+ store=store,
563
+ project_root=settings.project_root,
564
+ projects=settings.projects,
565
+ )
566
+ except ProjectError as error:
567
+ raise StartupRejected(str(error)) from error
568
+ channel = build_channel(settings, store)
569
+ except BaseException:
570
+ store.close()
571
+ raise
572
+ core = ServiceCore(
573
+ settings=settings,
574
+ channel=channel,
575
+ store=store,
576
+ projects=projects,
577
+ instance_root=instance_root,
578
+ )
579
+ _log_startup_timing("Projects and Channel construction", stage_started_at)
580
+ stop_event = asyncio.Event()
581
+ loop = asyncio.get_running_loop()
582
+ for signal_name in (signal.SIGINT, signal.SIGTERM):
583
+ try:
584
+ loop.add_signal_handler(signal_name, stop_event.set)
585
+ except NotImplementedError: # pragma: no cover - Windows
586
+ pass
587
+
588
+ core_started = False
589
+ try:
590
+ await _await_channel_future(channel.schedule(core.start()))
591
+ core_started = True
592
+ assert core.application is not None
593
+ stage_started_at = time.monotonic()
594
+ await _start_channel_input(channel, core.application, ready_file=ready_file)
595
+ _log_startup_timing("Feishu connection", stage_started_at)
596
+ await _await_channel_future(channel.schedule(_open_core_admission(core, ready_file=ready_file)))
597
+ logger.info(
598
+ "netizen service ready",
599
+ extra={"projects": len(projects.list())},
600
+ )
601
+ await stop_event.wait()
602
+ finally:
603
+ try:
604
+ if core_started:
605
+ await _await_channel_future(channel.schedule(core.close()))
606
+ finally:
607
+ try:
608
+ await asyncio.to_thread(channel.stop)
609
+ if not core._closed:
610
+ store.close()
611
+ finally:
612
+ if ready_file is not None:
613
+ _clear_ready_marker(ready_file)
614
+
615
+
616
+ async def _start_channel_input(
617
+ channel: FeishuChannel,
618
+ application: ChannelApplication,
619
+ *,
620
+ ready_file: Path | None,
621
+ ) -> None:
622
+ _register_channel_handlers(channel, application)
623
+ await channel.start_background()
624
+
625
+
626
+ async def _open_core_admission(core: ServiceCore, *, ready_file: Path | None = None) -> None:
627
+ core.open_admission()
628
+ if ready_file is not None:
629
+ _publish_ready_marker(ready_file)
630
+ assert core._management is not None
631
+ core._management.set_service_ready(True)
632
+
633
+
634
+ async def _await_channel_future(
635
+ future: concurrent.futures.Future[Any],
636
+ ) -> Any:
637
+ return await asyncio.wrap_future(future)
638
+
639
+
640
+ async def _cleanup_step(
641
+ label: str,
642
+ operation: Awaitable[object],
643
+ *,
644
+ timeout: float,
645
+ ) -> bool:
646
+ try:
647
+ await asyncio.wait_for(operation, timeout=timeout)
648
+ except TimeoutError:
649
+ logger.warning("%s timed out after %.1fs", label, timeout)
650
+ return False
651
+ except Exception:
652
+ logger.exception("%s failed", label)
653
+ return False
654
+ return True
655
+
656
+
657
+ async def _cleanup_with_budget(
658
+ label: str,
659
+ operation: Callable[[], Awaitable[object]],
660
+ *,
661
+ deadline: float,
662
+ cap: float,
663
+ ) -> bool:
664
+ remaining = min(cap, deadline - asyncio.get_running_loop().time())
665
+ if remaining <= 0:
666
+ logger.warning("%s skipped because the shutdown budget was exhausted", label)
667
+ return False
668
+ return await _cleanup_step(label, operation(), timeout=remaining)
669
+
670
+
671
+ def _log_channel_error(error: object) -> None:
672
+ logger.error("Feishu Channel error: %s", type(error).__name__)
673
+
674
+
675
+ def _register_channel_handlers(
676
+ channel: FeishuChannel,
677
+ application: ChannelApplication,
678
+ ) -> None:
679
+ channel.on(Events.MESSAGE, application.handle_message)
680
+ channel.on(Events.CARD_ACTION, application.handle_card_action)
681
+ channel.on(Events.ERROR, _log_channel_error)
682
+
683
+
684
+ def _scrub_channel_environment() -> None:
685
+ # Hygiene only: with the accepted same-user/full-access Pilot boundary,
686
+ # Codex can still read the protected secret file if explicitly instructed.
687
+ # NETIZEN_ROOT stays as the non-secret, exact instance context for tools.
688
+ os.environ.pop("FEISHU_APP_SECRET", None)
689
+ os.environ.pop("FEISHU_APP_SECRET_FILE", None)
690
+ os.environ.pop("NETIZEN_LARK_APP_CONFIG", None)
691
+ os.environ.pop("NETIZEN_ADMIN_SECRET", None)
692
+ os.environ.pop("NETIZEN_ADMIN_SECRET_FILE", None)
693
+ os.environ.pop("NETIZEN_CONFIG_PATH", None)
694
+ os.environ.pop("NETIZEN_LOG_FILE", None)
695
+ os.environ.pop("NETIZEN_MANAGED_LAUNCH_AGENT", None)
696
+ os.environ.pop("NETIZEN_READY_FILE", None)
697
+
698
+
699
+ def _managed_absolute_path(raw: str, *, label: str) -> Path:
700
+ path = Path(raw)
701
+ if not path.is_absolute() or path == Path(path.anchor):
702
+ raise StartupRejected(f"{label} must be an absolute non-root path: {path}")
703
+ return path
704
+
705
+
706
+ def _validate_instance_path(path: Path, expected: Path, *, label: str) -> None:
707
+ if not path.is_absolute() or path.resolve() != expected:
708
+ raise StartupRejected(f"{label} must belong to NETIZEN_ROOT: expected {expected}, got {path}")
709
+
710
+
711
+ def _validate_instance_environment(
712
+ root: Path, config_path: Path, environment: dict[str, str],
713
+ ) -> None:
714
+ """Reject mixed instance paths before opening logs, credentials or state."""
715
+ _validate_instance_path(config_path, root / "config.yaml", label="NETIZEN_CONFIG_PATH")
716
+ expected_paths = {
717
+ "NETIZEN_LARK_APP_CONFIG": root / "lark-app" / "config.json",
718
+ "NETIZEN_ADMIN_SECRET_FILE": root / "credentials" / "admin-web-secret",
719
+ "NETIZEN_LOG_FILE": root / "state" / "netizen.log",
720
+ "NETIZEN_READY_FILE": root / "state" / "service.ready",
721
+ "NETIZEN_LIFETIME_LOCK_FILE": root / "state" / "service.lifetime.lock",
722
+ }
723
+ for name, expected in expected_paths.items():
724
+ if name in environment:
725
+ _validate_instance_path(Path(environment[name]), expected, label=name)
726
+
727
+
728
+ def _adopt_lifetime_lock() -> int | None:
729
+ raw_descriptor = os.environ.pop("NETIZEN_LIFETIME_LOCK_FD", None)
730
+ raw_path = os.environ.pop("NETIZEN_LIFETIME_LOCK_FILE", None)
731
+ if raw_descriptor is None and raw_path is None:
732
+ return None
733
+ if raw_descriptor is None or raw_path is None:
734
+ raise StartupRejected("managed service lifetime lock environment is incomplete")
735
+ if not raw_descriptor.isdecimal():
736
+ raise StartupRejected("NETIZEN_LIFETIME_LOCK_FD is not a file descriptor")
737
+ descriptor = int(raw_descriptor)
738
+ lock_path = _managed_absolute_path(
739
+ raw_path,
740
+ label="NETIZEN_LIFETIME_LOCK_FILE",
741
+ )
742
+ if lock_path.is_symlink():
743
+ raise StartupRejected(f"service lifetime lock must not be a symlink: {lock_path}")
744
+ try:
745
+ descriptor_metadata = os.fstat(descriptor)
746
+ path_metadata = lock_path.stat()
747
+ except OSError as error:
748
+ raise StartupRejected(f"could not adopt service lifetime lock: {error}") from error
749
+ if (
750
+ not stat.S_ISREG(descriptor_metadata.st_mode)
751
+ or descriptor_metadata.st_uid != os.geteuid()
752
+ or (descriptor_metadata.st_dev, descriptor_metadata.st_ino)
753
+ != (path_metadata.st_dev, path_metadata.st_ino)
754
+ ):
755
+ raise StartupRejected("service lifetime lock FD does not match its managed path")
756
+ os.set_inheritable(descriptor, False)
757
+ return descriptor
758
+
759
+
760
+ def _publish_ready_marker(path: Path) -> None:
761
+ temporary = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}"
762
+ descriptor: int | None = None
763
+ try:
764
+ descriptor = os.open(
765
+ temporary,
766
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
767
+ 0o600,
768
+ )
769
+ view = memoryview(_READY_MARKER_CONTENT)
770
+ while view:
771
+ written = os.write(descriptor, view)
772
+ view = view[written:]
773
+ os.fsync(descriptor)
774
+ os.close(descriptor)
775
+ descriptor = None
776
+ os.replace(temporary, path)
777
+ os.chmod(path, 0o600)
778
+ except OSError as error:
779
+ raise RuntimeError(f"could not publish service ready marker {path}: {error}") from error
780
+ finally:
781
+ if descriptor is not None:
782
+ with contextlib.suppress(OSError):
783
+ os.close(descriptor)
784
+ with contextlib.suppress(OSError):
785
+ temporary.unlink()
786
+
787
+
788
+ def _clear_ready_marker(path: Path) -> None:
789
+ if not path.exists() and not path.is_symlink():
790
+ return
791
+ if path.is_dir() and not path.is_symlink():
792
+ logger.error("service ready marker became a directory: %s", path)
793
+ return
794
+ try:
795
+ path.unlink()
796
+ except OSError:
797
+ logger.exception("failed to clear service ready marker")
798
+
799
+
800
+ def _configure_logging() -> None:
801
+ raw_log_file = os.environ.get("NETIZEN_LOG_FILE", "")
802
+ if not raw_log_file.strip():
803
+ logging.basicConfig(
804
+ level=logging.INFO,
805
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
806
+ force=True,
807
+ )
808
+ return
809
+ log_file = _managed_absolute_path(raw_log_file, label="NETIZEN_LOG_FILE")
810
+ if log_file.is_symlink() or (log_file.exists() and not log_file.is_file()):
811
+ raise RuntimeError(f"managed service log is not a regular file: {log_file}")
812
+ handler = RotatingFileHandler(
813
+ log_file,
814
+ maxBytes=_LOG_MAX_BYTES,
815
+ backupCount=_LOG_BACKUP_COUNT,
816
+ encoding="utf-8",
817
+ )
818
+ os.chmod(log_file, 0o600)
819
+ logging.basicConfig(
820
+ level=logging.INFO,
821
+ format="%(asctime)s %(levelname)s %(name)s %(message)s",
822
+ handlers=[handler],
823
+ force=True,
824
+ )
825
+
826
+
827
+ def main() -> None:
828
+ try:
829
+ _run_service()
830
+ except StartupRejected as error:
831
+ if os.environ.get("NETIZEN_CLI_SERVICE") != "1":
832
+ raise
833
+ # systemd Restart=on-failure and launchd SuccessfulExit=false must not
834
+ # loop over unchanged invalid data. No readiness marker is published;
835
+ # `netizen start` still reports failure when readiness is not reached.
836
+ logger.error("Netizen startup rejected: %s; repair the instance and start it again", error)
837
+
838
+
839
+ def _run_service() -> None:
840
+ startup_environment = dict(os.environ)
841
+ lifetime_descriptor = _adopt_lifetime_lock()
842
+ try:
843
+ stage_started_at = time.monotonic()
844
+ if lifetime_descriptor is not None and "NETIZEN_ROOT" not in startup_environment:
845
+ raise StartupRejected("managed service environment is missing NETIZEN_ROOT")
846
+ try:
847
+ instance_root = resolve_instance_root(environ=startup_environment)
848
+ except ValueError as error:
849
+ raise StartupRejected(str(error)) from error
850
+ config_path = Path(startup_environment.get(
851
+ "NETIZEN_CONFIG_PATH",
852
+ str(instance_root / "config.yaml"),
853
+ ))
854
+ _validate_instance_environment(instance_root, config_path, startup_environment)
855
+ # The entry point owns the instance context for both managed and manual
856
+ # starts. Export it before creating any Codex/tool subprocesses.
857
+ os.environ["NETIZEN_ROOT"] = str(instance_root)
858
+ os.environ["NETIZEN_LARK_APP_CONFIG"] = str(instance_root / "lark-app" / "config.json")
859
+ os.environ["NETIZEN_ADMIN_SECRET_FILE"] = str(instance_root / "credentials" / "admin-web-secret")
860
+ _configure_platform_trust()
861
+ raw_ready_file = startup_environment.get("NETIZEN_READY_FILE", "")
862
+ if lifetime_descriptor is not None and not raw_ready_file:
863
+ raise StartupRejected("managed service environment is missing NETIZEN_READY_FILE")
864
+ ready_file = (
865
+ _managed_absolute_path(raw_ready_file, label="NETIZEN_READY_FILE")
866
+ if lifetime_descriptor is not None
867
+ else None
868
+ )
869
+ try:
870
+ settings = Settings.from_file(config_path)
871
+ except (ValueError, FileNotFoundError, PermissionError) as error:
872
+ raise StartupRejected(str(error)) from error
873
+ _validate_instance_path(settings.data_dir, instance_root / "state", label="instance.dataDir")
874
+ if settings.admin_web.credential_path is not None:
875
+ _validate_instance_path(
876
+ settings.admin_web.credential_path,
877
+ instance_root / "credentials" / "admin-web-secret",
878
+ label="Admin credential path",
879
+ )
880
+ if settings.admin_web.enabled:
881
+ try:
882
+ load_credential_snapshot(settings.admin_web.credential_path)
883
+ except CredentialFileError as error:
884
+ raise StartupRejected(str(error)) from error
885
+ if lifetime_descriptor is None:
886
+ try:
887
+ lifetime_descriptor = acquire_lifetime_lock(instance_root)
888
+ except (InstanceDataError, FileNotFoundError, PermissionError) as error:
889
+ raise StartupRejected(str(error)) from error
890
+ prepare_instance(instance_root, lifetime_descriptor=lifetime_descriptor)
891
+ _configure_logging()
892
+ logger.info("netizen startup: module imports completed in %.3fs", _IMPORT_SECONDS)
893
+ _scrub_channel_environment()
894
+ _log_startup_timing("configuration", stage_started_at)
895
+ asyncio.run(run(settings, instance_root=instance_root, ready_file=ready_file))
896
+ finally:
897
+ if lifetime_descriptor is not None:
898
+ with contextlib.suppress(OSError):
899
+ os.close(lifetime_descriptor)
900
+
901
+
902
+ if __name__ == "__main__":
903
+ main()