androidctl 0.1.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.
- androidctl/__init__.py +5 -0
- androidctl/__main__.py +4 -0
- androidctl/_version.py +1 -0
- androidctl/app.py +73 -0
- androidctl/cli_options.py +27 -0
- androidctl/command_payloads.py +264 -0
- androidctl/command_views.py +157 -0
- androidctl/commands/__init__.py +1 -0
- androidctl/commands/actions.py +236 -0
- androidctl/commands/adb_wireless.py +157 -0
- androidctl/commands/close.py +30 -0
- androidctl/commands/connect.py +69 -0
- androidctl/commands/execute.py +179 -0
- androidctl/commands/list_apps.py +26 -0
- androidctl/commands/observe.py +26 -0
- androidctl/commands/open.py +41 -0
- androidctl/commands/plumbing.py +58 -0
- androidctl/commands/run_pipeline.py +307 -0
- androidctl/commands/screenshot.py +29 -0
- androidctl/commands/setup.py +301 -0
- androidctl/commands/wait.py +60 -0
- androidctl/daemon/__init__.py +1 -0
- androidctl/daemon/client.py +348 -0
- androidctl/daemon/discovery.py +190 -0
- androidctl/daemon/launcher.py +26 -0
- androidctl/daemon/owner.py +349 -0
- androidctl/errors/__init__.py +1 -0
- androidctl/errors/mapping.py +149 -0
- androidctl/errors/models.py +16 -0
- androidctl/exit_codes.py +8 -0
- androidctl/output.py +147 -0
- androidctl/parsing/__init__.py +1 -0
- androidctl/parsing/duration.py +17 -0
- androidctl/parsing/open_target.py +51 -0
- androidctl/parsing/refs.py +12 -0
- androidctl/parsing/screen_id.py +10 -0
- androidctl/parsing/wait.py +70 -0
- androidctl/renderers/__init__.py +110 -0
- androidctl/renderers/_paths.py +109 -0
- androidctl/renderers/xml.py +234 -0
- androidctl/renderers/xml_projection.py +732 -0
- androidctl/resources/__init__.py +1 -0
- androidctl/resources/androidctl-agent-0.1.0-release.apk +0 -0
- androidctl/setup/__init__.py +1 -0
- androidctl/setup/accessibility.py +159 -0
- androidctl/setup/adb.py +586 -0
- androidctl/setup/apk_resource.py +29 -0
- androidctl/setup/pairing.py +70 -0
- androidctl/setup/verify.py +175 -0
- androidctl/workspace/__init__.py +3 -0
- androidctl/workspace/resolve.py +27 -0
- androidctl-0.1.0.dist-info/METADATA +217 -0
- androidctl-0.1.0.dist-info/RECORD +187 -0
- androidctl-0.1.0.dist-info/WHEEL +5 -0
- androidctl-0.1.0.dist-info/entry_points.txt +3 -0
- androidctl-0.1.0.dist-info/licenses/LICENSE +674 -0
- androidctl-0.1.0.dist-info/top_level.txt +3 -0
- androidctl_contracts/__init__.py +55 -0
- androidctl_contracts/_version.py +1 -0
- androidctl_contracts/_wire_helpers.py +31 -0
- androidctl_contracts/base.py +142 -0
- androidctl_contracts/command_catalog.py +414 -0
- androidctl_contracts/command_results.py +630 -0
- androidctl_contracts/daemon_api.py +335 -0
- androidctl_contracts/errors.py +44 -0
- androidctl_contracts/paths.py +5 -0
- androidctl_contracts/public_screen.py +579 -0
- androidctl_contracts/user_state.py +23 -0
- androidctl_contracts/vocabulary.py +82 -0
- androidctld/__init__.py +5 -0
- androidctld/__main__.py +63 -0
- androidctld/_version.py +1 -0
- androidctld/actions/__init__.py +1 -0
- androidctld/actions/action_target.py +142 -0
- androidctld/actions/capabilities.py +539 -0
- androidctld/actions/executor.py +894 -0
- androidctld/actions/focus_confirmation.py +177 -0
- androidctld/actions/focused_input_admissibility.py +120 -0
- androidctld/actions/fresh_current.py +176 -0
- androidctld/actions/postconditions.py +473 -0
- androidctld/actions/repair.py +101 -0
- androidctld/actions/request_builder.py +204 -0
- androidctld/actions/settle.py +146 -0
- androidctld/actions/submit_confirmation.py +211 -0
- androidctld/actions/submit_routing.py +311 -0
- androidctld/actions/type_confirmation.py +257 -0
- androidctld/app_targets.py +71 -0
- androidctld/artifacts/__init__.py +1 -0
- androidctld/artifacts/models.py +26 -0
- androidctld/artifacts/screen_lookup.py +241 -0
- androidctld/artifacts/screen_payloads.py +109 -0
- androidctld/artifacts/writer.py +286 -0
- androidctld/auth/__init__.py +1 -0
- androidctld/auth/active_registry.py +266 -0
- androidctld/auth/secret_files.py +52 -0
- androidctld/auth/token_store.py +59 -0
- androidctld/commands/__init__.py +1 -0
- androidctld/commands/assembly.py +231 -0
- androidctld/commands/command_models.py +254 -0
- androidctld/commands/dispatch.py +99 -0
- androidctld/commands/executor.py +31 -0
- androidctld/commands/from_boundary.py +175 -0
- androidctld/commands/handlers/__init__.py +15 -0
- androidctld/commands/handlers/action.py +439 -0
- androidctld/commands/handlers/connect.py +94 -0
- androidctld/commands/handlers/list_apps.py +215 -0
- androidctld/commands/handlers/observe.py +121 -0
- androidctld/commands/handlers/screenshot.py +105 -0
- androidctld/commands/handlers/wait.py +286 -0
- androidctld/commands/models.py +65 -0
- androidctld/commands/open_targets.py +56 -0
- androidctld/commands/orchestration.py +353 -0
- androidctld/commands/registry.py +116 -0
- androidctld/commands/result_builders.py +40 -0
- androidctld/commands/result_models.py +555 -0
- androidctld/commands/results.py +108 -0
- androidctld/commands/semantic_command_names.py +17 -0
- androidctld/commands/semantic_error_mapping.py +93 -0
- androidctld/commands/semantic_truth.py +135 -0
- androidctld/commands/service.py +67 -0
- androidctld/config.py +75 -0
- androidctld/daemon/__init__.py +1 -0
- androidctld/daemon/active_slot.py +326 -0
- androidctld/daemon/envelope.py +30 -0
- androidctld/daemon/http_host.py +123 -0
- androidctld/daemon/ingress.py +112 -0
- androidctld/daemon/ownership_probe.py +204 -0
- androidctld/daemon/server.py +286 -0
- androidctld/daemon/service.py +99 -0
- androidctld/device/__init__.py +1 -0
- androidctld/device/action_models.py +154 -0
- androidctld/device/action_serialization.py +121 -0
- androidctld/device/adapters.py +220 -0
- androidctld/device/bootstrap.py +153 -0
- androidctld/device/connectors.py +231 -0
- androidctld/device/errors.py +100 -0
- androidctld/device/interfaces.py +58 -0
- androidctld/device/parsing.py +320 -0
- androidctld/device/rpc.py +483 -0
- androidctld/device/schema.py +114 -0
- androidctld/device/types.py +161 -0
- androidctld/errors/__init__.py +94 -0
- androidctld/logging/__init__.py +22 -0
- androidctld/observation.py +98 -0
- androidctld/protocol.py +53 -0
- androidctld/refs/__init__.py +1 -0
- androidctld/refs/models.py +54 -0
- androidctld/refs/repair.py +284 -0
- androidctld/refs/service.py +422 -0
- androidctld/rendering/__init__.py +1 -0
- androidctld/rendering/screen_xml.py +256 -0
- androidctld/runtime/__init__.py +21 -0
- androidctld/runtime/kernel.py +548 -0
- androidctld/runtime/lifecycle.py +19 -0
- androidctld/runtime/models.py +48 -0
- androidctld/runtime/screen_state.py +117 -0
- androidctld/runtime/state_repo.py +70 -0
- androidctld/runtime/store.py +76 -0
- androidctld/runtime_policy.py +127 -0
- androidctld/schema/__init__.py +5 -0
- androidctld/schema/base.py +132 -0
- androidctld/schema/core.py +35 -0
- androidctld/schema/daemon_api.py +108 -0
- androidctld/schema/persistence.py +161 -0
- androidctld/schema/persistence_io.py +41 -0
- androidctld/schema/validation_errors.py +309 -0
- androidctld/semantics/__init__.py +1 -0
- androidctld/semantics/compiler.py +610 -0
- androidctld/semantics/continuity.py +107 -0
- androidctld/semantics/labels.py +252 -0
- androidctld/semantics/models.py +25 -0
- androidctld/semantics/policy.py +23 -0
- androidctld/semantics/public_models.py +123 -0
- androidctld/semantics/registries.py +13 -0
- androidctld/semantics/submit_refs.py +417 -0
- androidctld/semantics/surface.py +254 -0
- androidctld/semantics/targets.py +167 -0
- androidctld/snapshots/__init__.py +1 -0
- androidctld/snapshots/models.py +219 -0
- androidctld/snapshots/refresh.py +273 -0
- androidctld/snapshots/schema.py +74 -0
- androidctld/snapshots/service.py +138 -0
- androidctld/text_equivalence.py +67 -0
- androidctld/waits/__init__.py +1 -0
- androidctld/waits/evaluators.py +216 -0
- androidctld/waits/loop.py +305 -0
- androidctld/waits/matcher.py +41 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from androidctl.daemon.client import (
|
|
14
|
+
DaemonApiError,
|
|
15
|
+
DaemonClient,
|
|
16
|
+
DaemonProtocolError,
|
|
17
|
+
IncompatibleDaemonError,
|
|
18
|
+
try_get_healthy_daemon,
|
|
19
|
+
)
|
|
20
|
+
from androidctl.daemon.launcher import resolve_launch_spec
|
|
21
|
+
from androidctl.daemon.owner import derive_owner_id
|
|
22
|
+
from androidctl_contracts.paths import daemon_state_root
|
|
23
|
+
from androidctl_contracts.user_state import ActiveDaemonRecord
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_daemon_client(
|
|
27
|
+
*,
|
|
28
|
+
workspace_root: Path,
|
|
29
|
+
cwd: Path,
|
|
30
|
+
env: Mapping[str, str],
|
|
31
|
+
) -> DaemonClient:
|
|
32
|
+
resolved_workspace_root = workspace_root.resolve()
|
|
33
|
+
owner_id = derive_owner_id(env=env)
|
|
34
|
+
existing = _healthy_client_from_record(
|
|
35
|
+
_read_active_daemon_record(resolved_workspace_root),
|
|
36
|
+
workspace_root=resolved_workspace_root,
|
|
37
|
+
owner_id=owner_id,
|
|
38
|
+
)
|
|
39
|
+
if existing is not None:
|
|
40
|
+
return existing
|
|
41
|
+
|
|
42
|
+
_launch_daemon_process(
|
|
43
|
+
cwd=cwd,
|
|
44
|
+
env=env,
|
|
45
|
+
workspace_root=resolved_workspace_root,
|
|
46
|
+
owner_id=owner_id,
|
|
47
|
+
)
|
|
48
|
+
launched = _wait_for_healthy_client(
|
|
49
|
+
workspace_root=resolved_workspace_root,
|
|
50
|
+
owner_id=owner_id,
|
|
51
|
+
timeout_seconds=5.0,
|
|
52
|
+
)
|
|
53
|
+
if launched is not None:
|
|
54
|
+
return launched
|
|
55
|
+
raise RuntimeError("failed to discover or launch a healthy androidctld daemon")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def discover_existing_daemon_client(
|
|
59
|
+
*,
|
|
60
|
+
workspace_root: Path,
|
|
61
|
+
env: Mapping[str, str],
|
|
62
|
+
) -> DaemonClient | None:
|
|
63
|
+
resolved_workspace_root = workspace_root.resolve()
|
|
64
|
+
owner_id = derive_owner_id(env=env)
|
|
65
|
+
return _healthy_client_from_record(
|
|
66
|
+
_read_active_daemon_record(resolved_workspace_root),
|
|
67
|
+
workspace_root=resolved_workspace_root,
|
|
68
|
+
owner_id=owner_id,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _healthy_client_from_record(
|
|
73
|
+
record: ActiveDaemonRecord | None,
|
|
74
|
+
*,
|
|
75
|
+
workspace_root: Path,
|
|
76
|
+
owner_id: str,
|
|
77
|
+
) -> DaemonClient | None:
|
|
78
|
+
if record is None:
|
|
79
|
+
return None
|
|
80
|
+
if record.workspace_root != workspace_root.as_posix():
|
|
81
|
+
return None
|
|
82
|
+
if not _is_loopback_host(record.host):
|
|
83
|
+
raise RuntimeError(f"active daemon host must be loopback: {record.host!r}")
|
|
84
|
+
client = DaemonClient.from_active_record(record, owner_id=owner_id)
|
|
85
|
+
if record.owner_id != owner_id:
|
|
86
|
+
try:
|
|
87
|
+
client.health(record)
|
|
88
|
+
except IncompatibleDaemonError as error:
|
|
89
|
+
raise DaemonApiError(
|
|
90
|
+
code="WORKSPACE_BUSY",
|
|
91
|
+
message="workspace daemon is owned by a different shell or agent",
|
|
92
|
+
details={"ownerId": record.owner_id},
|
|
93
|
+
) from error
|
|
94
|
+
except DaemonApiError as error:
|
|
95
|
+
if error.code == "WORKSPACE_BUSY":
|
|
96
|
+
raise error
|
|
97
|
+
return None
|
|
98
|
+
except (DaemonProtocolError, httpx.RequestError, httpx.HTTPStatusError):
|
|
99
|
+
return None
|
|
100
|
+
raise DaemonApiError(
|
|
101
|
+
code="WORKSPACE_BUSY",
|
|
102
|
+
message="workspace daemon is owned by a different shell or agent",
|
|
103
|
+
details={"ownerId": record.owner_id},
|
|
104
|
+
)
|
|
105
|
+
try:
|
|
106
|
+
health = try_get_healthy_daemon(client, record)
|
|
107
|
+
except IncompatibleDaemonError:
|
|
108
|
+
raise
|
|
109
|
+
except DaemonProtocolError:
|
|
110
|
+
return None
|
|
111
|
+
if health is None:
|
|
112
|
+
return None
|
|
113
|
+
return client
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _wait_for_healthy_client(
|
|
117
|
+
*,
|
|
118
|
+
workspace_root: Path,
|
|
119
|
+
owner_id: str,
|
|
120
|
+
timeout_seconds: float,
|
|
121
|
+
) -> DaemonClient | None:
|
|
122
|
+
deadline = time.monotonic() + timeout_seconds
|
|
123
|
+
while time.monotonic() < deadline:
|
|
124
|
+
client = _healthy_client_from_record(
|
|
125
|
+
_read_active_daemon_record(workspace_root),
|
|
126
|
+
workspace_root=workspace_root,
|
|
127
|
+
owner_id=owner_id,
|
|
128
|
+
)
|
|
129
|
+
if client is not None:
|
|
130
|
+
return client
|
|
131
|
+
time.sleep(0.05)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _launch_daemon_process(
|
|
136
|
+
*,
|
|
137
|
+
cwd: Path,
|
|
138
|
+
env: Mapping[str, str],
|
|
139
|
+
workspace_root: Path,
|
|
140
|
+
owner_id: str,
|
|
141
|
+
) -> None:
|
|
142
|
+
launch_spec = resolve_launch_spec(env=dict(env))
|
|
143
|
+
process_env = dict(env)
|
|
144
|
+
if launch_spec.env_overlay:
|
|
145
|
+
process_env.update(launch_spec.env_overlay)
|
|
146
|
+
log_dir = daemon_state_root(workspace_root) / "logs"
|
|
147
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
timestamp = int(time.time() * 1000)
|
|
149
|
+
log_path = log_dir / f"androidctld-{timestamp}.log"
|
|
150
|
+
log_file = log_path.open("a", buffering=1)
|
|
151
|
+
subprocess.Popen(
|
|
152
|
+
[
|
|
153
|
+
launch_spec.executable,
|
|
154
|
+
*launch_spec.argv,
|
|
155
|
+
"--workspace-root",
|
|
156
|
+
str(workspace_root),
|
|
157
|
+
"--owner-id",
|
|
158
|
+
owner_id,
|
|
159
|
+
],
|
|
160
|
+
cwd=launch_spec.cwd or cwd,
|
|
161
|
+
env=process_env,
|
|
162
|
+
stdin=subprocess.DEVNULL,
|
|
163
|
+
stdout=log_file,
|
|
164
|
+
stderr=log_file,
|
|
165
|
+
start_new_session=True,
|
|
166
|
+
)
|
|
167
|
+
log_file.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _read_active_daemon_record(workspace_root: Path) -> ActiveDaemonRecord | None:
|
|
171
|
+
active_path = daemon_state_root(workspace_root.resolve()) / "active.json"
|
|
172
|
+
if not active_path.exists():
|
|
173
|
+
return None
|
|
174
|
+
try:
|
|
175
|
+
payload = json.loads(active_path.read_text(encoding="utf-8"))
|
|
176
|
+
return ActiveDaemonRecord.model_validate(payload)
|
|
177
|
+
except (ValueError, json.JSONDecodeError, ValidationError):
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_loopback_host(host: str) -> bool:
|
|
182
|
+
normalized = host.strip().lower()
|
|
183
|
+
if normalized == "localhost":
|
|
184
|
+
return True
|
|
185
|
+
if normalized.startswith("[") and normalized.endswith("]"):
|
|
186
|
+
normalized = normalized[1:-1]
|
|
187
|
+
try:
|
|
188
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
189
|
+
except ValueError:
|
|
190
|
+
return False
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class LaunchSpec:
|
|
12
|
+
executable: str
|
|
13
|
+
argv: tuple[str, ...] = ()
|
|
14
|
+
env_overlay: dict[str, str] | None = None
|
|
15
|
+
cwd: Path | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_launch_spec(
|
|
19
|
+
*,
|
|
20
|
+
env: Mapping[str, str] | None = None,
|
|
21
|
+
) -> LaunchSpec:
|
|
22
|
+
merged_env = os.environ if env is None else env
|
|
23
|
+
env_bin = merged_env.get("ANDROIDCTLD_BIN")
|
|
24
|
+
if env_bin:
|
|
25
|
+
return LaunchSpec(executable=env_bin)
|
|
26
|
+
return LaunchSpec(executable=sys.executable, argv=("-m", "androidctld"))
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ctypes
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from ctypes import wintypes
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
OWNER_ENV = "ANDROIDCTL_OWNER_ID"
|
|
13
|
+
DEFAULT_OWNER_HINT = "Set ANDROIDCTL_OWNER_ID explicitly."
|
|
14
|
+
_MAX_OWNER_PROCESS_HOPS = 64
|
|
15
|
+
_SHELL_PROCESS_NAMES = frozenset(
|
|
16
|
+
{"bash", "zsh", "fish", "sh", "ksh", "dash", "tcsh", "csh"}
|
|
17
|
+
)
|
|
18
|
+
_WINDOWS_SHELL_PROCESS_NAMES = frozenset(
|
|
19
|
+
{"bash.exe", "cmd.exe", "powershell.exe", "pwsh.exe", "sh.exe"}
|
|
20
|
+
)
|
|
21
|
+
_TH32CS_SNAPPROCESS = 0x00000002
|
|
22
|
+
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
23
|
+
_ERROR_NO_MORE_FILES = 18
|
|
24
|
+
_WINDOWS_INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class _WindowsProcessInfo:
|
|
29
|
+
parent_pid: int
|
|
30
|
+
process_name: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _WindowsFileTime(ctypes.Structure):
|
|
34
|
+
_fields_ = [
|
|
35
|
+
("dwLowDateTime", wintypes.DWORD),
|
|
36
|
+
("dwHighDateTime", wintypes.DWORD),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _WindowsProcessEntry32(ctypes.Structure):
|
|
41
|
+
_fields_ = [
|
|
42
|
+
("dwSize", wintypes.DWORD),
|
|
43
|
+
("cntUsage", wintypes.DWORD),
|
|
44
|
+
("th32ProcessID", wintypes.DWORD),
|
|
45
|
+
("th32DefaultHeapID", ctypes.c_void_p),
|
|
46
|
+
("th32ModuleID", wintypes.DWORD),
|
|
47
|
+
("cntThreads", wintypes.DWORD),
|
|
48
|
+
("th32ParentProcessID", wintypes.DWORD),
|
|
49
|
+
("pcPriClassBase", wintypes.LONG),
|
|
50
|
+
("dwFlags", wintypes.DWORD),
|
|
51
|
+
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def derive_owner_id(*, env: Mapping[str, str]) -> str:
|
|
56
|
+
configured = env.get(OWNER_ENV)
|
|
57
|
+
if configured is not None:
|
|
58
|
+
candidate = configured.strip()
|
|
59
|
+
if candidate:
|
|
60
|
+
return candidate
|
|
61
|
+
if sys.platform == "win32":
|
|
62
|
+
owner_id = _derive_windows_owner_id()
|
|
63
|
+
if owner_id is None:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"Unable to derive a safe owner identity automatically. "
|
|
66
|
+
f"{DEFAULT_OWNER_HINT}"
|
|
67
|
+
)
|
|
68
|
+
return owner_id
|
|
69
|
+
shell_pid = _find_interactive_shell_ancestor_pid(env)
|
|
70
|
+
if shell_pid is None:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"Unable to derive a safe owner identity automatically. "
|
|
73
|
+
f"{DEFAULT_OWNER_HINT}"
|
|
74
|
+
)
|
|
75
|
+
lifetime = _read_process_lifetime_discriminator(shell_pid)
|
|
76
|
+
if lifetime is None:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Unable to derive a safe owner identity automatically. "
|
|
79
|
+
f"{DEFAULT_OWNER_HINT}"
|
|
80
|
+
)
|
|
81
|
+
return f"shell:{shell_pid}:{lifetime}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _find_interactive_shell_ancestor_pid(env: Mapping[str, str]) -> int | None:
|
|
85
|
+
del env
|
|
86
|
+
current_pid = os.getpid()
|
|
87
|
+
ancestor_pid = _read_parent_pid(current_pid)
|
|
88
|
+
if ancestor_pid is None:
|
|
89
|
+
return None
|
|
90
|
+
seen: set[int] = {current_pid}
|
|
91
|
+
hops = 0
|
|
92
|
+
while ancestor_pid > 1 and hops < _MAX_OWNER_PROCESS_HOPS:
|
|
93
|
+
if ancestor_pid in seen:
|
|
94
|
+
return None
|
|
95
|
+
seen.add(ancestor_pid)
|
|
96
|
+
process_name = _read_process_name(ancestor_pid)
|
|
97
|
+
if process_name is not None and process_name.casefold() in _SHELL_PROCESS_NAMES:
|
|
98
|
+
interactivity = _read_shell_interactivity(ancestor_pid)
|
|
99
|
+
if interactivity is None:
|
|
100
|
+
return None
|
|
101
|
+
if interactivity:
|
|
102
|
+
return ancestor_pid
|
|
103
|
+
next_pid = _read_parent_pid(ancestor_pid)
|
|
104
|
+
if next_pid is None:
|
|
105
|
+
return None
|
|
106
|
+
ancestor_pid = next_pid
|
|
107
|
+
hops += 1
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _derive_windows_owner_id() -> str | None:
|
|
112
|
+
shell_pid = _find_windows_shell_ancestor_pid()
|
|
113
|
+
if shell_pid is None:
|
|
114
|
+
return None
|
|
115
|
+
lifetime = _read_windows_process_creation_filetime(shell_pid)
|
|
116
|
+
if lifetime is None:
|
|
117
|
+
return None
|
|
118
|
+
return f"shell:win32:{shell_pid}:{lifetime}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _find_windows_shell_ancestor_pid() -> int | None:
|
|
122
|
+
process_table = _read_windows_process_table()
|
|
123
|
+
if process_table is None:
|
|
124
|
+
return None
|
|
125
|
+
current_pid = os.getpid()
|
|
126
|
+
current = process_table.get(current_pid)
|
|
127
|
+
if current is None:
|
|
128
|
+
return None
|
|
129
|
+
ancestor_pid = current.parent_pid
|
|
130
|
+
seen: set[int] = {current_pid}
|
|
131
|
+
hops = 0
|
|
132
|
+
while ancestor_pid > 0 and hops < _MAX_OWNER_PROCESS_HOPS:
|
|
133
|
+
if ancestor_pid in seen:
|
|
134
|
+
return None
|
|
135
|
+
seen.add(ancestor_pid)
|
|
136
|
+
ancestor = process_table.get(ancestor_pid)
|
|
137
|
+
if ancestor is None:
|
|
138
|
+
return None
|
|
139
|
+
process_name = _normalize_windows_process_name(ancestor.process_name)
|
|
140
|
+
if process_name in _WINDOWS_SHELL_PROCESS_NAMES:
|
|
141
|
+
return ancestor_pid
|
|
142
|
+
ancestor_pid = ancestor.parent_pid
|
|
143
|
+
hops += 1
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _normalize_windows_process_name(process_name: str) -> str:
|
|
148
|
+
normalized = process_name.strip().replace("/", "\\")
|
|
149
|
+
return normalized.rsplit("\\", maxsplit=1)[-1].casefold()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _read_windows_process_table() -> dict[int, _WindowsProcessInfo] | None:
|
|
153
|
+
kernel32 = _load_windows_kernel32()
|
|
154
|
+
if kernel32 is None:
|
|
155
|
+
return None
|
|
156
|
+
_configure_windows_process_snapshot_functions(kernel32)
|
|
157
|
+
snapshot = kernel32.CreateToolhelp32Snapshot(_TH32CS_SNAPPROCESS, 0)
|
|
158
|
+
snapshot_value = _windows_handle_value(snapshot)
|
|
159
|
+
if snapshot_value is None or snapshot_value == _WINDOWS_INVALID_HANDLE_VALUE:
|
|
160
|
+
return None
|
|
161
|
+
try:
|
|
162
|
+
entry = _WindowsProcessEntry32()
|
|
163
|
+
entry.dwSize = ctypes.sizeof(_WindowsProcessEntry32)
|
|
164
|
+
if not kernel32.Process32FirstW(snapshot, ctypes.byref(entry)):
|
|
165
|
+
return None
|
|
166
|
+
process_table: dict[int, _WindowsProcessInfo] = {}
|
|
167
|
+
while True:
|
|
168
|
+
pid = int(entry.th32ProcessID)
|
|
169
|
+
if pid > 0:
|
|
170
|
+
process_table[pid] = _WindowsProcessInfo(
|
|
171
|
+
parent_pid=int(entry.th32ParentProcessID),
|
|
172
|
+
process_name=str(entry.szExeFile),
|
|
173
|
+
)
|
|
174
|
+
_set_windows_last_error(0)
|
|
175
|
+
if not kernel32.Process32NextW(snapshot, ctypes.byref(entry)):
|
|
176
|
+
if _get_windows_last_error() != _ERROR_NO_MORE_FILES:
|
|
177
|
+
return None
|
|
178
|
+
break
|
|
179
|
+
return process_table
|
|
180
|
+
finally:
|
|
181
|
+
kernel32.CloseHandle(snapshot)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _read_windows_process_creation_filetime(pid: int) -> str | None:
|
|
185
|
+
kernel32 = _load_windows_kernel32()
|
|
186
|
+
if kernel32 is None:
|
|
187
|
+
return None
|
|
188
|
+
_configure_windows_process_time_functions(kernel32)
|
|
189
|
+
process = kernel32.OpenProcess(
|
|
190
|
+
_PROCESS_QUERY_LIMITED_INFORMATION,
|
|
191
|
+
False,
|
|
192
|
+
pid,
|
|
193
|
+
)
|
|
194
|
+
process_value = _windows_handle_value(process)
|
|
195
|
+
if process_value is None or process_value == 0:
|
|
196
|
+
return None
|
|
197
|
+
try:
|
|
198
|
+
creation_time = _WindowsFileTime()
|
|
199
|
+
exit_time = _WindowsFileTime()
|
|
200
|
+
kernel_time = _WindowsFileTime()
|
|
201
|
+
user_time = _WindowsFileTime()
|
|
202
|
+
ok = kernel32.GetProcessTimes(
|
|
203
|
+
process,
|
|
204
|
+
ctypes.byref(creation_time),
|
|
205
|
+
ctypes.byref(exit_time),
|
|
206
|
+
ctypes.byref(kernel_time),
|
|
207
|
+
ctypes.byref(user_time),
|
|
208
|
+
)
|
|
209
|
+
if not ok:
|
|
210
|
+
return None
|
|
211
|
+
filetime = (int(creation_time.dwHighDateTime) << 32) | int(
|
|
212
|
+
creation_time.dwLowDateTime
|
|
213
|
+
)
|
|
214
|
+
if filetime <= 0:
|
|
215
|
+
return None
|
|
216
|
+
return str(filetime)
|
|
217
|
+
finally:
|
|
218
|
+
kernel32.CloseHandle(process)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _load_windows_kernel32() -> Any | None:
|
|
222
|
+
windll = getattr(ctypes, "WinDLL", None)
|
|
223
|
+
if windll is None:
|
|
224
|
+
return None
|
|
225
|
+
try:
|
|
226
|
+
return windll("kernel32", use_last_error=True)
|
|
227
|
+
except OSError:
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _set_windows_last_error(error_code: int) -> None:
|
|
232
|
+
setter = getattr(ctypes, "set_last_error", None)
|
|
233
|
+
if setter is not None:
|
|
234
|
+
setter(error_code)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _get_windows_last_error() -> int:
|
|
238
|
+
getter = getattr(ctypes, "get_last_error", None)
|
|
239
|
+
if getter is None:
|
|
240
|
+
return 0
|
|
241
|
+
return int(getter())
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _configure_windows_process_snapshot_functions(kernel32: Any) -> None:
|
|
245
|
+
kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD]
|
|
246
|
+
kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE
|
|
247
|
+
kernel32.Process32FirstW.argtypes = [
|
|
248
|
+
wintypes.HANDLE,
|
|
249
|
+
ctypes.POINTER(_WindowsProcessEntry32),
|
|
250
|
+
]
|
|
251
|
+
kernel32.Process32FirstW.restype = wintypes.BOOL
|
|
252
|
+
kernel32.Process32NextW.argtypes = [
|
|
253
|
+
wintypes.HANDLE,
|
|
254
|
+
ctypes.POINTER(_WindowsProcessEntry32),
|
|
255
|
+
]
|
|
256
|
+
kernel32.Process32NextW.restype = wintypes.BOOL
|
|
257
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
258
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _configure_windows_process_time_functions(kernel32: Any) -> None:
|
|
262
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
263
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
264
|
+
kernel32.GetProcessTimes.argtypes = [
|
|
265
|
+
wintypes.HANDLE,
|
|
266
|
+
ctypes.POINTER(_WindowsFileTime),
|
|
267
|
+
ctypes.POINTER(_WindowsFileTime),
|
|
268
|
+
ctypes.POINTER(_WindowsFileTime),
|
|
269
|
+
ctypes.POINTER(_WindowsFileTime),
|
|
270
|
+
]
|
|
271
|
+
kernel32.GetProcessTimes.restype = wintypes.BOOL
|
|
272
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
273
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _windows_handle_value(handle: object) -> int | None:
|
|
277
|
+
if handle is None:
|
|
278
|
+
return None
|
|
279
|
+
if isinstance(handle, int):
|
|
280
|
+
return handle
|
|
281
|
+
value = getattr(handle, "value", None)
|
|
282
|
+
if isinstance(value, int):
|
|
283
|
+
return value
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _read_shell_interactivity(pid: int) -> bool | None:
|
|
288
|
+
tty_nr = _read_process_tty_nr(pid)
|
|
289
|
+
if tty_nr is None:
|
|
290
|
+
return None
|
|
291
|
+
return tty_nr != "0"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _read_process_name(pid: int) -> str | None:
|
|
295
|
+
try:
|
|
296
|
+
return Path(f"/proc/{pid}/comm").read_text(encoding="utf-8").strip()
|
|
297
|
+
except (OSError, RuntimeError):
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _read_parent_pid(pid: int) -> int | None:
|
|
302
|
+
parts = _read_process_stat_fields(pid)
|
|
303
|
+
if parts is None:
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
return int(parts[3])
|
|
307
|
+
except ValueError:
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _read_process_lifetime_discriminator(pid: int) -> str | None:
|
|
312
|
+
parts = _read_process_stat_fields(pid)
|
|
313
|
+
if parts is None or len(parts) < 22:
|
|
314
|
+
return None
|
|
315
|
+
start_ticks = parts[21].strip()
|
|
316
|
+
if not start_ticks:
|
|
317
|
+
return None
|
|
318
|
+
return start_ticks
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _read_process_tty_nr(pid: int) -> str | None:
|
|
322
|
+
parts = _read_process_stat_fields(pid)
|
|
323
|
+
if parts is None or len(parts) < 7:
|
|
324
|
+
return None
|
|
325
|
+
tty_nr = parts[6].strip()
|
|
326
|
+
if not tty_nr:
|
|
327
|
+
return None
|
|
328
|
+
return tty_nr
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _read_process_stat_fields(pid: int) -> list[str] | None:
|
|
332
|
+
try:
|
|
333
|
+
raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
|
334
|
+
except OSError:
|
|
335
|
+
return None
|
|
336
|
+
stat = raw.strip()
|
|
337
|
+
first_space = stat.find(" ")
|
|
338
|
+
comm_end = stat.rfind(")")
|
|
339
|
+
if first_space <= 0 or comm_end <= first_space:
|
|
340
|
+
return None
|
|
341
|
+
pid_part = stat[:first_space]
|
|
342
|
+
comm_part = stat[first_space + 1 : comm_end + 1]
|
|
343
|
+
remainder = stat[comm_end + 1 :].strip()
|
|
344
|
+
if not comm_part.startswith("("):
|
|
345
|
+
return None
|
|
346
|
+
parts = [pid_part, comm_part, *remainder.split()]
|
|
347
|
+
if len(parts) < 4:
|
|
348
|
+
return None
|
|
349
|
+
return parts
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Public CLI error mapping models and helpers."""
|