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.
- netizen_cli/__init__.py +3 -0
- netizen_cli/__main__.py +4 -0
- netizen_cli/admin/__init__.py +1 -0
- netizen_cli/admin/auth.py +928 -0
- netizen_cli/admin/errors.py +9 -0
- netizen_cli/admin/port_config.py +115 -0
- netizen_cli/admin/presentation.py +257 -0
- netizen_cli/admin/queries.py +337 -0
- netizen_cli/admin/static/admin.css +260 -0
- netizen_cli/admin/static/admin.js +2898 -0
- netizen_cli/admin/static/index.html +327 -0
- netizen_cli/admin/transport.py +935 -0
- netizen_cli/admin/web.py +2717 -0
- netizen_cli/bindings.py +3215 -0
- netizen_cli/builtin_skills.py +93 -0
- netizen_cli/cards/__init__.py +105 -0
- netizen_cli/cards/callbacks.py +565 -0
- netizen_cli/cards/controls.py +2273 -0
- netizen_cli/cards/defaults.py +213 -0
- netizen_cli/cards/model_info.py +80 -0
- netizen_cli/cards/questions.py +220 -0
- netizen_cli/cards/reply.py +2247 -0
- netizen_cli/cards/scheduled.py +836 -0
- netizen_cli/channel/__init__.py +1 -0
- netizen_cli/channel/completion_mentions.py +60 -0
- netizen_cli/channel/input_preparation.py +644 -0
- netizen_cli/channel/messages.py +57 -0
- netizen_cli/channel/ports.py +52 -0
- netizen_cli/channel/question_inputs.py +51 -0
- netizen_cli/channel/reactions.py +293 -0
- netizen_cli/channel/reply_presenter.py +1505 -0
- netizen_cli/channel/topics.py +70 -0
- netizen_cli/channel_app.py +6593 -0
- netizen_cli/cli.py +287 -0
- netizen_cli/cli_data.py +536 -0
- netizen_cli/cli_packages.py +526 -0
- netizen_cli/cli_services.py +651 -0
- netizen_cli/cli_setup.py +242 -0
- netizen_cli/cli_update.py +303 -0
- netizen_cli/cli_update_restore.py +53 -0
- netizen_cli/cli_update_worker.py +333 -0
- netizen_cli/codex_runtime.py +7125 -0
- netizen_cli/completion_mention.py +16 -0
- netizen_cli/database_migrations.py +218 -0
- netizen_cli/defaults/__init__.py +5 -0
- netizen_cli/defaults/models.py +39 -0
- netizen_cli/defaults/service.py +232 -0
- netizen_cli/defaults/store.py +260 -0
- netizen_cli/deployment/__init__.py +1 -0
- netizen_cli/deployment/restart_worker.py +134 -0
- netizen_cli/deployment/update_executor.py +258 -0
- netizen_cli/deployment/update_protocol.py +281 -0
- netizen_cli/domain.py +416 -0
- netizen_cli/error_messages.py +124 -0
- netizen_cli/experience.py +531 -0
- netizen_cli/feishu_app_onboarding.py +187 -0
- netizen_cli/feishu_app_permissions.py +123 -0
- netizen_cli/git_status.py +63 -0
- netizen_cli/image_inputs.py +579 -0
- netizen_cli/instance.py +84 -0
- netizen_cli/lark_app.py +125 -0
- netizen_cli/main.py +903 -0
- netizen_cli/management/__init__.py +83 -0
- netizen_cli/management/blocking_io.py +352 -0
- netizen_cli/management/chat_labels.py +266 -0
- netizen_cli/management/coordination.py +32 -0
- netizen_cli/management/service.py +2187 -0
- netizen_cli/management/updates.py +214 -0
- netizen_cli/markdown_images.py +78 -0
- netizen_cli/message_content.py +786 -0
- netizen_cli/message_history.py +643 -0
- netizen_cli/message_preparation.py +60 -0
- netizen_cli/message_projection.py +923 -0
- netizen_cli/migrations/__init__.py +1 -0
- netizen_cli/migrations/schema.py +103 -0
- netizen_cli/migrations/v14.py +438 -0
- netizen_cli/model_settings.py +269 -0
- netizen_cli/package_resources.py +22 -0
- netizen_cli/projects.py +327 -0
- netizen_cli/prompt_projection.py +327 -0
- netizen_cli/quoted_context.py +312 -0
- netizen_cli/resources/config.example.yaml +35 -0
- netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
- netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
- netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
- netizen_cli/result_images.py +123 -0
- netizen_cli/runtime/__init__.py +1 -0
- netizen_cli/runtime/contracts.py +792 -0
- netizen_cli/runtime/name_writes.py +67 -0
- netizen_cli/runtime/thread_naming.py +451 -0
- netizen_cli/schedules/__init__.py +1 -0
- netizen_cli/schedules/mcp.py +535 -0
- netizen_cli/schedules/models.py +394 -0
- netizen_cli/schedules/scheduler.py +374 -0
- netizen_cli/schedules/service.py +766 -0
- netizen_cli/schedules/store.py +771 -0
- netizen_cli/sdk_gap_adapter.py +1151 -0
- netizen_cli/service_launcher.py +583 -0
- netizen_cli/session_settings.py +126 -0
- netizen_cli/settings.py +216 -0
- netizen_cli/skill_references.py +40 -0
- netizen_cli/terminal_cleanup.py +155 -0
- netizen_cli/turn_activity.py +688 -0
- netizen_cli/turn_files.py +812 -0
- netizen_cli/turn_patch_children.py +254 -0
- netizen_cli/turn_plan_observer.py +315 -0
- netizen_cli/user_questions.py +106 -0
- netizen_cli-0.10.0.dist-info/METADATA +18 -0
- netizen_cli-0.10.0.dist-info/RECORD +112 -0
- netizen_cli-0.10.0.dist-info/WHEEL +5 -0
- netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
- 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)
|