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,583 @@
1
+ """Launch the installed Netizen with the account's exported shell environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import fcntl
7
+ import hashlib
8
+ import json
9
+ import os
10
+ import pwd
11
+ import select
12
+ import shlex
13
+ import signal
14
+ import stat
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ import time
19
+ import uuid
20
+ from collections.abc import Mapping
21
+ from pathlib import Path
22
+
23
+ from netizen_cli.instance import require_instance_root_marker
24
+
25
+
26
+ PROFILE_CAPTURE_TIMEOUT_SECONDS = 10.0
27
+ PROFILE_CAPTURE_MAX_BYTES = 4 * 1024 * 1024
28
+ _POSIX_PROFILE_SHELLS = frozenset({"bash", "dash", "ksh", "mksh", "sh", "zsh"})
29
+ _SCRUBBED_PYTHON_ENVIRONMENT = (
30
+ "PYTHONHOME",
31
+ "PYTHONPATH",
32
+ "VIRTUAL_ENV",
33
+ "__PYVENV_LAUNCHER__",
34
+ )
35
+
36
+
37
+ class ServiceLaunchError(RuntimeError):
38
+ """The service cannot safely acquire its shell environment or start."""
39
+
40
+
41
+ class _ProfileOutputTooLarge(RuntimeError):
42
+ pass
43
+
44
+
45
+ def _profile_shell_argv(shell: Path, command: str) -> list[str]:
46
+ name = shell.name
47
+ if name in _POSIX_PROFILE_SHELLS:
48
+ return [str(shell), "-lic", command]
49
+ if name == "fish":
50
+ return [str(shell), "--login", "--interactive", "--command", command]
51
+ raise ServiceLaunchError(
52
+ f"unsupported account login shell {shell}; supported shells are "
53
+ "bash, dash, fish, ksh, mksh, sh, and zsh"
54
+ )
55
+
56
+
57
+ def _environment_probe_command(
58
+ python_executable: Path,
59
+ start_token: str,
60
+ end_token: str,
61
+ ) -> str:
62
+ code = (
63
+ "import hashlib,os,sys; "
64
+ "out=sys.stdout.buffer; "
65
+ "start=os.fsencode(sys.argv[1]); end=os.fsencode(sys.argv[2]); "
66
+ "payload=b''.join(key+b'='+value+b'\\0' for key,value in os.environb.items()); "
67
+ "digest=hashlib.sha256(payload).hexdigest().encode(); "
68
+ "out.write(b'\\0'+start+b'\\0'+str(len(payload)).encode()+b'\\0'"
69
+ "+digest+b'\\0'+payload+b'\\0'+end+b'\\0')"
70
+ )
71
+ # Replacing the login shell prevents Bash/Zsh logout hooks from running.
72
+ # Those hooks belong to an ending terminal session, not to starting a
73
+ # long-lived service with the account's startup environment.
74
+ return "exec " + shlex.join(
75
+ (
76
+ str(python_executable),
77
+ "-E",
78
+ "-P",
79
+ "-B",
80
+ "-u",
81
+ "-c",
82
+ code,
83
+ start_token,
84
+ end_token,
85
+ )
86
+ )
87
+
88
+
89
+ def _terminate_process_group(process: subprocess.Popen[bytes]) -> None:
90
+ with contextlib.suppress(ProcessLookupError):
91
+ os.killpg(process.pid, signal.SIGKILL)
92
+ with contextlib.suppress(subprocess.TimeoutExpired):
93
+ process.wait(timeout=1)
94
+
95
+
96
+ def _read_profile_snapshot(
97
+ read_fd: int,
98
+ process: subprocess.Popen[bytes],
99
+ *,
100
+ end_token: str,
101
+ timeout: float,
102
+ ) -> bytes:
103
+ deadline = time.monotonic() + timeout
104
+ end_marker = b"\0" + os.fsencode(end_token) + b"\0"
105
+ output = bytearray()
106
+ while end_marker not in output:
107
+ remaining = deadline - time.monotonic()
108
+ if remaining <= 0:
109
+ raise subprocess.TimeoutExpired(process.args, timeout)
110
+ readable, _, _ = select.select((read_fd,), (), (), remaining)
111
+ if not readable:
112
+ raise subprocess.TimeoutExpired(process.args, timeout)
113
+ chunk = os.read(read_fd, 64 * 1024)
114
+ if not chunk:
115
+ break
116
+ output.extend(chunk)
117
+ if len(output) > PROFILE_CAPTURE_MAX_BYTES:
118
+ raise _ProfileOutputTooLarge
119
+
120
+ remaining = deadline - time.monotonic()
121
+ if remaining <= 0:
122
+ raise subprocess.TimeoutExpired(process.args, timeout)
123
+ process.wait(timeout=remaining)
124
+ return bytes(output)
125
+
126
+
127
+ def _parse_environment_dump(
128
+ output: bytes,
129
+ *,
130
+ start_token: str,
131
+ end_token: str,
132
+ ) -> dict[str, str]:
133
+ start_marker = b"\0" + os.fsencode(start_token) + b"\0"
134
+ start = output.rfind(start_marker)
135
+ if start < 0:
136
+ raise ServiceLaunchError(
137
+ "account shell profile completed without returning an environment snapshot"
138
+ )
139
+
140
+ framed = output[start + len(start_marker) :]
141
+ length_text, separator, framed = framed.partition(b"\0")
142
+ digest, digest_separator, framed = framed.partition(b"\0")
143
+ if (
144
+ not separator
145
+ or not digest_separator
146
+ or not length_text.isdigit()
147
+ or len(length_text) > 10
148
+ or len(digest) != 64
149
+ ):
150
+ raise ServiceLaunchError(
151
+ "account shell profile returned an invalid environment snapshot"
152
+ )
153
+ length = int(length_text)
154
+ if length > PROFILE_CAPTURE_MAX_BYTES:
155
+ raise ServiceLaunchError(
156
+ "account shell profile returned an invalid environment snapshot"
157
+ )
158
+ payload = framed[:length]
159
+ trailer = framed[length:]
160
+ end_marker = b"\0" + os.fsencode(end_token) + b"\0"
161
+ if (
162
+ len(payload) != length
163
+ or not trailer.startswith(end_marker)
164
+ or hashlib.sha256(payload).hexdigest().encode() != digest
165
+ ):
166
+ raise ServiceLaunchError(
167
+ "account shell profile environment snapshot failed its integrity check"
168
+ )
169
+
170
+ environment: dict[str, str] = {}
171
+ for entry in payload.split(b"\0"):
172
+ if not entry:
173
+ continue
174
+ name, separator, value = entry.partition(b"=")
175
+ if not separator or not name:
176
+ raise ServiceLaunchError(
177
+ "account shell profile returned an invalid environment snapshot"
178
+ )
179
+ environment[os.fsdecode(name)] = os.fsdecode(value)
180
+ return environment
181
+
182
+
183
+ def capture_profile_environment(
184
+ *,
185
+ shell: Path,
186
+ home: Path,
187
+ username: str,
188
+ python_executable: Path,
189
+ base_environment: Mapping[str, str] | None = None,
190
+ timeout: float = PROFILE_CAPTURE_TIMEOUT_SECONDS,
191
+ ) -> dict[str, str]:
192
+ """Run one bounded interactive login shell and return its exported environment."""
193
+
194
+ if not shell.is_absolute() or not shell.is_file() or not os.access(shell, os.X_OK):
195
+ raise ServiceLaunchError(f"account login shell is not executable: {shell}")
196
+ if timeout <= 0:
197
+ raise ServiceLaunchError("shell profile capture timeout must be positive")
198
+
199
+ source = os.environ if base_environment is None else base_environment
200
+ bootstrap = dict(source)
201
+ bootstrap.update(
202
+ {
203
+ "HOME": str(home),
204
+ "LOGNAME": username,
205
+ "SHELL": str(shell),
206
+ "USER": username,
207
+ }
208
+ )
209
+ bootstrap.setdefault("PATH", "/usr/local/bin:/usr/bin:/bin")
210
+ token = uuid.uuid4().hex
211
+ start_token = f"NETIZEN_ENV_START_{token}"
212
+ end_token = f"NETIZEN_ENV_END_{token}"
213
+ try:
214
+ command = _environment_probe_command(
215
+ python_executable,
216
+ start_token,
217
+ end_token,
218
+ )
219
+ argv = _profile_shell_argv(shell, command)
220
+ process = subprocess.Popen(
221
+ argv,
222
+ cwd=home,
223
+ env=bootstrap,
224
+ stdin=subprocess.DEVNULL,
225
+ stdout=subprocess.PIPE,
226
+ stderr=subprocess.DEVNULL,
227
+ start_new_session=True,
228
+ umask=0o077,
229
+ )
230
+ except OSError as error:
231
+ raise ServiceLaunchError(f"could not start account login shell {shell}: {error}") from error
232
+ assert process.stdout is not None
233
+ try:
234
+ stdout = _read_profile_snapshot(
235
+ process.stdout.fileno(),
236
+ process,
237
+ end_token=end_token,
238
+ timeout=timeout,
239
+ )
240
+ except subprocess.TimeoutExpired as error:
241
+ _terminate_process_group(process)
242
+ raise ServiceLaunchError(
243
+ f"account shell profile did not finish within {timeout:g}s: {shell}"
244
+ ) from error
245
+ except _ProfileOutputTooLarge as error:
246
+ _terminate_process_group(process)
247
+ raise ServiceLaunchError(
248
+ "account shell profile output exceeded the 4 MiB safety limit"
249
+ ) from error
250
+ except OSError as error:
251
+ _terminate_process_group(process)
252
+ raise ServiceLaunchError(
253
+ f"could not read account shell profile environment: {error}"
254
+ ) from error
255
+ except BaseException:
256
+ _terminate_process_group(process)
257
+ raise
258
+ finally:
259
+ process.stdout.close()
260
+ if process.returncode != 0:
261
+ _terminate_process_group(process)
262
+ raise ServiceLaunchError(
263
+ f"account shell profile exited with status {process.returncode}: {shell}"
264
+ )
265
+ try:
266
+ return _parse_environment_dump(
267
+ stdout,
268
+ start_token=start_token,
269
+ end_token=end_token,
270
+ )
271
+ except ServiceLaunchError:
272
+ _terminate_process_group(process)
273
+ raise
274
+
275
+
276
+ def service_environment(
277
+ captured: Mapping[str, str],
278
+ *,
279
+ instance_root: Path,
280
+ home: Path,
281
+ username: str,
282
+ shell: Path,
283
+ codex_home: str | None,
284
+ config_path: str,
285
+ lark_app_config: str,
286
+ admin_secret_file: str,
287
+ ready_file: str,
288
+ lifetime_lock_file: str,
289
+ log_file: str | None = None,
290
+ launch_agent_sentinel: str | None = None,
291
+ cli_prefix: str | None = None,
292
+ ) -> dict[str, str]:
293
+ """Preserve the shell snapshot while enforcing Netizen-owned launch values."""
294
+
295
+ selected_codex_home = codex_home or captured.get("CODEX_HOME") or str(home / ".codex")
296
+ selected_codex_home = str(_managed_absolute_path(selected_codex_home, label="CODEX_HOME"))
297
+ managed_paths = {
298
+ "NETIZEN_CONFIG_PATH": config_path,
299
+ "NETIZEN_LARK_APP_CONFIG": lark_app_config,
300
+ "NETIZEN_ADMIN_SECRET_FILE": admin_secret_file,
301
+ "NETIZEN_READY_FILE": ready_file,
302
+ "NETIZEN_LIFETIME_LOCK_FILE": lifetime_lock_file,
303
+ }
304
+ if log_file is not None:
305
+ managed_paths["NETIZEN_LOG_FILE"] = log_file
306
+ _validate_instance_paths(instance_root, managed_paths)
307
+ environment = dict(captured)
308
+ environment.pop("NETIZEN_ROOT", None)
309
+ environment.pop("FEISHU_APP_SECRET", None)
310
+ environment.pop("FEISHU_APP_SECRET_FILE", None)
311
+ environment.pop("NETIZEN_LARK_APP_CONFIG", None)
312
+ environment.pop("NETIZEN_ADMIN_SECRET", None)
313
+ environment.pop("NETIZEN_ADMIN_SECRET_FILE", None)
314
+ environment.pop("NETIZEN_LIFETIME_LOCK_FD", None)
315
+ environment.pop("NETIZEN_LIFETIME_LOCK_FILE", None)
316
+ environment.pop("NETIZEN_LOG_FILE", None)
317
+ environment.pop("NETIZEN_MANAGED_LAUNCH_AGENT", None)
318
+ environment.pop("NETIZEN_READY_FILE", None)
319
+ environment.pop("NETIZEN_CLI_SERVICE", None)
320
+ environment.pop("NETIZEN_CLI_PREFIX", None)
321
+ for name in _SCRUBBED_PYTHON_ENVIRONMENT:
322
+ environment.pop(name, None)
323
+ environment.update(
324
+ {
325
+ "CODEX_HOME": selected_codex_home,
326
+ "NETIZEN_ROOT": str(instance_root),
327
+ "NETIZEN_LARK_APP_CONFIG": lark_app_config,
328
+ "HOME": str(home),
329
+ "LOGNAME": username,
330
+ "NETIZEN_CONFIG_PATH": config_path,
331
+ "NETIZEN_ADMIN_SECRET_FILE": admin_secret_file,
332
+ "NETIZEN_LIFETIME_LOCK_FILE": lifetime_lock_file,
333
+ "NETIZEN_READY_FILE": ready_file,
334
+ "NETIZEN_CLI_SERVICE": "1",
335
+ "PYTHONDONTWRITEBYTECODE": "1",
336
+ "PYTHONUNBUFFERED": "1",
337
+ "SHELL": str(shell),
338
+ "USER": username,
339
+ }
340
+ )
341
+ if log_file is not None:
342
+ environment["NETIZEN_LOG_FILE"] = log_file
343
+ if launch_agent_sentinel is not None:
344
+ environment["NETIZEN_MANAGED_LAUNCH_AGENT"] = launch_agent_sentinel
345
+ if cli_prefix is not None:
346
+ environment["NETIZEN_CLI_PREFIX"] = cli_prefix
347
+ return environment
348
+
349
+
350
+ def _optional_environment(name: str) -> str | None:
351
+ value = os.environ.get(name, "")
352
+ return value if value.strip() else None
353
+
354
+
355
+ def _managed_absolute_path(value: str, *, label: str) -> Path:
356
+ path = Path(value)
357
+ if (not path.is_absolute() or path == Path(path.anchor)
358
+ or any(ord(character) < 0x20 for character in value)):
359
+ raise ServiceLaunchError(f"{label} must be an absolute non-root path: {path}")
360
+ return path
361
+
362
+
363
+ def _instance_paths(root: Path) -> dict[str, str]:
364
+ return {
365
+ "NETIZEN_CONFIG_PATH": str(root / "config.yaml"),
366
+ "NETIZEN_LARK_APP_CONFIG": str(root / "lark-app" / "config.json"),
367
+ "NETIZEN_ADMIN_SECRET_FILE": str(root / "credentials" / "admin-web-secret"),
368
+ "NETIZEN_READY_FILE": str(root / "state" / "service.ready"),
369
+ "NETIZEN_LIFETIME_LOCK_FILE": str(root / "state" / "service.lifetime.lock"),
370
+ }
371
+
372
+
373
+ def _validate_instance_paths(root: Path, values: Mapping[str, str]) -> None:
374
+ root = _managed_absolute_path(str(root), label="NETIZEN_ROOT")
375
+ if root.resolve() != root:
376
+ raise ServiceLaunchError("NETIZEN_ROOT must be the canonical instance path")
377
+ expected = _instance_paths(root)
378
+ if "NETIZEN_LOG_FILE" in values:
379
+ expected["NETIZEN_LOG_FILE"] = str(root / "state" / "netizen.log")
380
+ for name, path in expected.items():
381
+ if values.get(name) != path:
382
+ raise ServiceLaunchError(f"managed {name} does not match NETIZEN_ROOT")
383
+
384
+
385
+ def acquire_lifetime_lock(path: Path) -> int:
386
+ """Acquire the stable service-lifetime inode and return its CLOEXEC FD."""
387
+
388
+ flags = os.O_RDWR | os.O_CREAT
389
+ if hasattr(os, "O_NOFOLLOW"):
390
+ flags |= os.O_NOFOLLOW
391
+ try:
392
+ descriptor = os.open(path, flags, 0o600)
393
+ except OSError as error:
394
+ raise ServiceLaunchError(f"could not open service lifetime lock {path}: {error}") from error
395
+ try:
396
+ metadata = os.fstat(descriptor)
397
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.geteuid():
398
+ raise ServiceLaunchError(
399
+ f"service lifetime lock is not a current-user regular file: {path}"
400
+ )
401
+ os.fchmod(descriptor, 0o600)
402
+ os.set_inheritable(descriptor, False)
403
+ try:
404
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
405
+ except BlockingIOError as error:
406
+ raise ServiceLaunchError("another Netizen service process still owns the lifetime lock") from error
407
+ return descriptor
408
+ except BaseException:
409
+ os.close(descriptor)
410
+ raise
411
+
412
+
413
+ def clear_ready_marker(path: Path) -> None:
414
+ if not path.exists() and not path.is_symlink():
415
+ return
416
+ if path.is_dir() and not path.is_symlink():
417
+ raise ServiceLaunchError(f"service ready marker is a directory: {path}")
418
+ try:
419
+ path.unlink()
420
+ except OSError as error:
421
+ raise ServiceLaunchError(f"could not clear service ready marker {path}: {error}") from error
422
+
423
+
424
+ def publish_service_identity(root: Path) -> tuple[int, int]:
425
+ """Publish this lock-owning process's identity, not a durable registration.
426
+
427
+ A successful exec keeps the PID. Readers must still prove a live manager PID,
428
+ its binding and the lifetime lock; this file alone never proves execution.
429
+ """
430
+ directory = root / "state"
431
+ info = directory.lstat()
432
+ if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
433
+ or stat.S_IMODE(info.st_mode) & 0o022):
434
+ raise ServiceLaunchError(f"unsafe service state directory: {directory}")
435
+ path = directory / "service.identity.json"
436
+ try:
437
+ previous = path.lstat()
438
+ except FileNotFoundError:
439
+ pass
440
+ else:
441
+ if (not stat.S_ISREG(previous.st_mode) or previous.st_uid != os.geteuid()
442
+ or previous.st_nlink != 1 or stat.S_IMODE(previous.st_mode) != 0o600):
443
+ raise ServiceLaunchError(f"unsafe service identity marker: {path}")
444
+ path.unlink()
445
+ payload = {
446
+ "format": 1, "pid": os.getpid(), "python": os.path.abspath(sys.executable),
447
+ "prefix": str(Path(sys.prefix).resolve()), "root": str(root),
448
+ }
449
+ descriptor, temporary = tempfile.mkstemp(prefix=".service.identity-", dir=directory)
450
+ try:
451
+ with os.fdopen(descriptor, "w", encoding="utf-8") as output:
452
+ json.dump(payload, output, separators=(",", ":"))
453
+ output.write("\n")
454
+ output.flush()
455
+ os.fsync(output.fileno())
456
+ published = os.fstat(output.fileno())
457
+ os.replace(temporary, path)
458
+ return published.st_dev, published.st_ino
459
+ finally:
460
+ Path(temporary).unlink(missing_ok=True)
461
+
462
+
463
+ def clear_own_service_identity(root: Path, identity: tuple[int, int]) -> None:
464
+ """Do not unlink evidence replaced by another process or an operator."""
465
+ path = root / "state" / "service.identity.json"
466
+ with contextlib.suppress(OSError):
467
+ current = path.lstat()
468
+ if stat.S_ISREG(current.st_mode) and (current.st_dev, current.st_ino) == identity:
469
+ path.unlink()
470
+
471
+
472
+ def launch(root: Path) -> None:
473
+ """Start the bound interpreter for the CLI's explicit canonical root.
474
+
475
+ Root-derived values may be omitted, but conflicting values fail before
476
+ lock/ready mutation. Profile PATH never selects another Python.
477
+ """
478
+ instance_root = _managed_absolute_path(str(root), label="NETIZEN_ROOT")
479
+ managed = {
480
+ name: _optional_environment(name) or value
481
+ for name, value in _instance_paths(instance_root).items()
482
+ }
483
+ codex_home = _optional_environment("CODEX_HOME")
484
+ if codex_home is not None:
485
+ managed["CODEX_HOME"] = str(_managed_absolute_path(codex_home, label="CODEX_HOME"))
486
+ managed["NETIZEN_CLI_PREFIX"] = _optional_environment("NETIZEN_CLI_PREFIX") or sys.prefix
487
+ if Path(managed["NETIZEN_CLI_PREFIX"]).resolve() != Path(sys.prefix).resolve():
488
+ raise ServiceLaunchError("service interpreter does not match its bound Python environment")
489
+ for name in ("NETIZEN_LOG_FILE", "NETIZEN_MANAGED_LAUNCH_AGENT"):
490
+ value = _optional_environment(name)
491
+ if value is not None:
492
+ managed[name] = value
493
+ # Reject a mixed service definition before touching either instance's lock
494
+ # or ready marker. Capture all launch values before loading the profile.
495
+ _validate_instance_paths(instance_root, managed)
496
+ try:
497
+ require_instance_root_marker(instance_root)
498
+ except (OSError, ValueError) as error:
499
+ raise ServiceLaunchError(f"could not validate instance root marker: {error}") from error
500
+ lifetime_lock_path = _managed_absolute_path(
501
+ managed["NETIZEN_LIFETIME_LOCK_FILE"],
502
+ label="NETIZEN_LIFETIME_LOCK_FILE",
503
+ )
504
+ ready_path = _managed_absolute_path(
505
+ managed["NETIZEN_READY_FILE"],
506
+ label="NETIZEN_READY_FILE",
507
+ )
508
+ lifetime_descriptor = acquire_lifetime_lock(lifetime_lock_path)
509
+ identity: tuple[int, int] | None = None
510
+ try:
511
+ clear_ready_marker(ready_path)
512
+ identity = publish_service_identity(instance_root)
513
+ _launch_with_lifetime_lock(
514
+ lifetime_descriptor,
515
+ instance_root=instance_root,
516
+ managed=managed,
517
+ lifetime_lock_path=lifetime_lock_path,
518
+ ready_path=ready_path,
519
+ )
520
+ finally:
521
+ if identity is not None:
522
+ clear_own_service_identity(instance_root, identity)
523
+ with contextlib.suppress(OSError):
524
+ os.set_inheritable(lifetime_descriptor, False)
525
+ with contextlib.suppress(OSError):
526
+ os.close(lifetime_descriptor)
527
+
528
+
529
+ def _launch_with_lifetime_lock(
530
+ lifetime_descriptor: int,
531
+ *,
532
+ instance_root: Path,
533
+ managed: Mapping[str, str],
534
+ lifetime_lock_path: Path,
535
+ ready_path: Path,
536
+ ) -> None:
537
+ try:
538
+ account = pwd.getpwuid(os.geteuid())
539
+ except KeyError as error:
540
+ raise ServiceLaunchError(
541
+ f"effective uid {os.geteuid()} has no account database entry"
542
+ ) from error
543
+ home = Path(account.pw_dir)
544
+ shell = Path(account.pw_shell)
545
+ profile_started_at = time.monotonic()
546
+ captured = capture_profile_environment(
547
+ shell=shell,
548
+ home=home,
549
+ username=account.pw_name,
550
+ python_executable=Path(sys.executable),
551
+ )
552
+ with contextlib.suppress(OSError):
553
+ print(
554
+ f"netizen startup: shell environment loaded in {time.monotonic() - profile_started_at:.3f}s",
555
+ file=sys.stderr,
556
+ flush=True,
557
+ )
558
+ environment = service_environment(
559
+ captured,
560
+ instance_root=instance_root,
561
+ home=home,
562
+ username=account.pw_name,
563
+ shell=shell,
564
+ codex_home=managed.get("CODEX_HOME"),
565
+ config_path=managed["NETIZEN_CONFIG_PATH"],
566
+ lark_app_config=managed["NETIZEN_LARK_APP_CONFIG"],
567
+ admin_secret_file=managed["NETIZEN_ADMIN_SECRET_FILE"],
568
+ ready_file=str(ready_path),
569
+ lifetime_lock_file=str(lifetime_lock_path),
570
+ log_file=managed.get("NETIZEN_LOG_FILE"),
571
+ launch_agent_sentinel=managed.get("NETIZEN_MANAGED_LAUNCH_AGENT"),
572
+ cli_prefix=managed["NETIZEN_CLI_PREFIX"],
573
+ )
574
+ environment["NETIZEN_LIFETIME_LOCK_FD"] = str(lifetime_descriptor)
575
+ os.set_inheritable(lifetime_descriptor, True)
576
+ try:
577
+ os.execve(
578
+ sys.executable,
579
+ [sys.executable, "-E", "-P", "-B", "-u", "-m", "netizen_cli.main"],
580
+ environment,
581
+ )
582
+ finally:
583
+ os.set_inheritable(lifetime_descriptor, False)