agentmetry 0.4.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.
- agentmetry/__init__.py +12 -0
- agentmetry/api/__init__.py +0 -0
- agentmetry/api/main.py +232 -0
- agentmetry/api/routes/__init__.py +0 -0
- agentmetry/api/routes/audit.py +396 -0
- agentmetry/api/websocket.py +53 -0
- agentmetry/api/ws_bridge.py +34 -0
- agentmetry/cli/__init__.py +941 -0
- agentmetry/cli/__main__.py +5 -0
- agentmetry/core/__init__.py +0 -0
- agentmetry/core/audit/__init__.py +1 -0
- agentmetry/core/audit/adapters/__init__.py +0 -0
- agentmetry/core/audit/adapters/agt.py +304 -0
- agentmetry/core/audit/adapters/cloudevents.py +159 -0
- agentmetry/core/audit/adapters/ecs.py +103 -0
- agentmetry/core/audit/adapters/splunk.py +40 -0
- agentmetry/core/audit/alerts.py +56 -0
- agentmetry/core/audit/canonical.py +150 -0
- agentmetry/core/audit/compliance_digest.py +299 -0
- agentmetry/core/audit/detection/__init__.py +9 -0
- agentmetry/core/audit/detection/benchmark.py +194 -0
- agentmetry/core/audit/detection/corpus/attack_approval_denied_then_executed.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_arbitrary_host_stage_execute.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_autonomous_unapproved_write.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_credential_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_credential_then_cloud_api.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_destructive_delete_burst.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_discovery_then_collect.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/attack_dotfile_then_git_push.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_encoded_command_download.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_env_credential_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_hashed_only_no_command.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_interpreter_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_pr_merged_without_review.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_proc_substitution_cradle.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_remote_pipe_to_shell.jsonl +1 -0
- agentmetry/core/audit/detection/corpus/attack_remote_staging_then_execute.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_session_tool_burst.jsonl +42 -0
- agentmetry/core/audit/detection/corpus/attack_single_command_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_ssh_directory_exfil.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/attack_subagent_swarm.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/attack_timestamp_collision.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/attack_untrusted_input_then_action.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_authoring_merge_fixtures.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_autonomous_after_approval.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_build_artifact_cleanup.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_ci_artifact_download.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_database_migration.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_dependency_install_and_build.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_download_release_archive.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_data_then_run_repo_script.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_dataset_then_analyse.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_fetch_lockfile_then_install.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_git_review_and_push.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_human_driven_deletes.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_local_api_probing.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_long_but_calm_session.jsonl +30 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_is_not_egress.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_loopback_pipe_to_interpreter.jsonl +3 -0
- agentmetry/core/audit/detection/corpus/benign_ordinary_development.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_package_manager_after_fetch.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_reading_config_that_is_not_secret.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_remote_api_call_no_credentials.jsonl +4 -0
- agentmetry/core/audit/detection/corpus/benign_research_then_docs.jsonl +5 -0
- agentmetry/core/audit/detection/corpus/benign_reversed_order_is_not_exfil.jsonl +2 -0
- agentmetry/core/audit/detection/corpus/benign_test_and_fix_loop.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/benign_writing_about_credentials.jsonl +6 -0
- agentmetry/core/audit/detection/corpus/corpus.yaml +443 -0
- agentmetry/core/audit/detection/disposition.py +651 -0
- agentmetry/core/audit/detection/engine.py +78 -0
- agentmetry/core/audit/detection/live.py +127 -0
- agentmetry/core/audit/detection/live_store.py +355 -0
- agentmetry/core/audit/detection/models.py +53 -0
- agentmetry/core/audit/detection/rules.py +1314 -0
- agentmetry/core/audit/detection/traits.py +648 -0
- agentmetry/core/audit/detection/yaml_config.py +91 -0
- agentmetry/core/audit/detection/yaml_rules.py +83 -0
- agentmetry/core/audit/dlp/__init__.py +4 -0
- agentmetry/core/audit/dlp/loader.py +29 -0
- agentmetry/core/audit/dlp/models.py +29 -0
- agentmetry/core/audit/dlp/scanner.py +96 -0
- agentmetry/core/audit/dogfood.py +398 -0
- agentmetry/core/audit/evidence_pack.py +500 -0
- agentmetry/core/audit/external.py +213 -0
- agentmetry/core/audit/hashing.py +21 -0
- agentmetry/core/audit/hook_bootstrap.py +451 -0
- agentmetry/core/audit/identity.py +39 -0
- agentmetry/core/audit/ingest.py +242 -0
- agentmetry/core/audit/migrate.py +73 -0
- agentmetry/core/audit/mitre.py +244 -0
- agentmetry/core/audit/policy.py +99 -0
- agentmetry/core/audit/redaction.py +50 -0
- agentmetry/core/audit/replay.py +54 -0
- agentmetry/core/audit/run_context.py +129 -0
- agentmetry/core/audit/sinks.py +235 -0
- agentmetry/core/audit/spool.py +394 -0
- agentmetry/core/audit/tool_policy/__init__.py +4 -0
- agentmetry/core/audit/tool_policy/evaluator.py +198 -0
- agentmetry/core/audit/tool_policy/loader.py +44 -0
- agentmetry/core/audit/tool_policy/models.py +25 -0
- agentmetry/core/audit/trail_chain.py +300 -0
- agentmetry/core/audit/trail_db.py +491 -0
- agentmetry/core/audit/trail_merkle.py +332 -0
- agentmetry/core/auth.py +54 -0
- agentmetry/core/bus/__init__.py +5 -0
- agentmetry/core/bus/audit_exporter.py +107 -0
- agentmetry/core/bus/bridges.py +26 -0
- agentmetry/core/bus/bus.py +102 -0
- agentmetry/core/bus/events.py +50 -0
- agentmetry/core/bus/outbox.py +124 -0
- agentmetry/core/config.py +177 -0
- agentmetry/core/diagnostics/__init__.py +0 -0
- agentmetry/core/diagnostics/autostart.py +563 -0
- agentmetry/core/diagnostics/doctor.py +535 -0
- agentmetry/core/diagnostics/driver_paths.py +156 -0
- agentmetry/core/diagnostics/env_file.py +45 -0
- agentmetry/core/drivers/__init__.py +4 -0
- agentmetry/core/drivers/host.py +263 -0
- agentmetry/core/drivers/permissions.py +37 -0
- agentmetry/core/drivers/spec.py +118 -0
- agentmetry/core/extensions.py +107 -0
- agentmetry/core/health.py +26 -0
- agentmetry/core/version.py +13 -0
- agentmetry/policies/detection/manifest.yaml +43 -0
- agentmetry/policies/dlp/manifest.yaml +161 -0
- agentmetry/policies/opa/agent_rules.rego +33 -0
- agentmetry/policies/tool/manifest.yaml +117 -0
- agentmetry-0.4.0.dist-info/METADATA +86 -0
- agentmetry-0.4.0.dist-info/RECORD +131 -0
- agentmetry-0.4.0.dist-info/WHEEL +4 -0
- agentmetry-0.4.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""Keep the recorder running across logouts, reboots, and crashes.
|
|
2
|
+
|
|
3
|
+
A flight recorder that only records while someone remembers to start it is not a
|
|
4
|
+
flight recorder. This module registers the orchestrator with whatever the
|
|
5
|
+
platform uses for user-scoped background work, and — just as importantly —
|
|
6
|
+
reports whether that registration actually exists.
|
|
7
|
+
|
|
8
|
+
The reporting half is the part that was missing, and it is the part that cost
|
|
9
|
+
real data. `agentmetry install` has existed for a while and registers a logon
|
|
10
|
+
task on Windows. Nobody had run it on the machine where this was written, so the
|
|
11
|
+
hooks captured into a spool for five days while the recorder was not running.
|
|
12
|
+
Nothing said so: not the dashboard, not `doctor`, not the API. A capability
|
|
13
|
+
nobody is told about is indistinguishable from one that does not exist, so
|
|
14
|
+
`status()` is treated here as a first-class feature rather than a helper.
|
|
15
|
+
|
|
16
|
+
Three deliberate choices:
|
|
17
|
+
|
|
18
|
+
**User scope, not system scope.** A logon task running as the interactive user
|
|
19
|
+
can raise toasts, reads the user's own config, and needs no elevation to install.
|
|
20
|
+
A system service would survive logout, and would also mean an audit daemon
|
|
21
|
+
running as LocalSystem reading a developer's home directory. That trade is the
|
|
22
|
+
enterprise packaging's to make, not the OSS default's.
|
|
23
|
+
|
|
24
|
+
**Restart on failure, not just start on logon.** The original registration was
|
|
25
|
+
`ONLOGON` with no restart policy, so a crashed recorder stayed dead until the
|
|
26
|
+
next logon. On a workstation that is days.
|
|
27
|
+
|
|
28
|
+
**Never silently.** Installing a persistent background process is the user's
|
|
29
|
+
decision. Nothing here runs as a side effect of a normal install; `doctor` warns
|
|
30
|
+
that autostart is absent and names the command, and stops there.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import os
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
TASK_NAME = "Agentmetry Orchestrator"
|
|
43
|
+
SERVICE_NAME = "agentmetry"
|
|
44
|
+
LAUNCH_LABEL = "ai.agentmetry.orchestrator"
|
|
45
|
+
|
|
46
|
+
# A crashed recorder should come back promptly and keep trying. The window that
|
|
47
|
+
# matters is the one where hooks are spooling because nothing is listening.
|
|
48
|
+
RESTART_INTERVAL_MINUTES = 1
|
|
49
|
+
RESTART_COUNT = 999
|
|
50
|
+
|
|
51
|
+
# Any boundary in the past works; the repetition is what carries the schedule
|
|
52
|
+
# forward. Fixed rather than "now" so a re-registration produces an identical
|
|
53
|
+
# task and diffs stay empty.
|
|
54
|
+
KEEPALIVE_EPOCH = "2020-01-01T00:00:00"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class AutostartStatus:
|
|
59
|
+
"""Whether autostart is registered, and separately, whether it works.
|
|
60
|
+
|
|
61
|
+
These are two different questions and conflating them cost real data. The
|
|
62
|
+
registration survived a package rename that broke the command it launches,
|
|
63
|
+
so the task ran every minute, exited 1 every minute, and `configured=True`
|
|
64
|
+
reported success the whole time. Hooks spooled behind it.
|
|
65
|
+
|
|
66
|
+
`healthy` is deliberately tri-state. `None` means the platform could not
|
|
67
|
+
tell us, which is not the same as failing, and reporting an unknown as a
|
|
68
|
+
fault would train an operator to ignore the field.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
configured: bool
|
|
72
|
+
backend: str
|
|
73
|
+
detail: str
|
|
74
|
+
healthy: bool | None = None
|
|
75
|
+
last_result: int | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _orchestrator_dir() -> Path:
|
|
79
|
+
"""The directory containing the `agentmetry` package, which is the working
|
|
80
|
+
directory an unattended `-m agentmetry.cli serve` needs."""
|
|
81
|
+
return Path(__file__).resolve().parents[3]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class PackagedBuild(RuntimeError):
|
|
85
|
+
"""Raised when a frozen build asks to register its own autostart.
|
|
86
|
+
|
|
87
|
+
Packaged builds are supervised by the service the installer registers, and a
|
|
88
|
+
second registration would race it. Inventing a launch command here to look
|
|
89
|
+
complete would produce a task that either fights the service or fails
|
|
90
|
+
silently, which is worse than saying so.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def launch_command() -> tuple[str, list[str], Path]:
|
|
95
|
+
"""(executable, arguments, working directory) for an unattended start.
|
|
96
|
+
|
|
97
|
+
From source we run the module directly rather than through a shell wrapper,
|
|
98
|
+
so there is no cmd.exe in the process tree for a restart policy to lose
|
|
99
|
+
track of.
|
|
100
|
+
"""
|
|
101
|
+
workdir = _orchestrator_dir()
|
|
102
|
+
if getattr(sys, "frozen", False):
|
|
103
|
+
raise PackagedBuild(
|
|
104
|
+
"This is a packaged build; the installer registers a service to keep "
|
|
105
|
+
"the orchestrator running. Manage it there rather than adding a "
|
|
106
|
+
"second autostart entry."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
executable = sys.executable
|
|
110
|
+
if os.name == "nt":
|
|
111
|
+
# pythonw.exe runs without a console window. Falling back to python.exe
|
|
112
|
+
# is better than failing: a visible window is a papercut, not being
|
|
113
|
+
# registered at all is data loss.
|
|
114
|
+
candidate = Path(sys.executable).with_name("pythonw.exe")
|
|
115
|
+
if candidate.is_file():
|
|
116
|
+
executable = str(candidate)
|
|
117
|
+
# `cli serve`, never `-m uvicorn` directly. Under pythonw.exe there is no
|
|
118
|
+
# console, so sys.stdout is None and uvicorn's own logging setup dies before
|
|
119
|
+
# it serves anything -- observed as a task that ran, exited 1, and explained
|
|
120
|
+
# nothing. `serve` points the streams at the log file first.
|
|
121
|
+
args = ["-m", "agentmetry.cli", "serve", "--host", "127.0.0.1", "--port", "8000"]
|
|
122
|
+
return executable, args, workdir
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ----------------------------------------------------------------------
|
|
126
|
+
# Rendering — pure, so the shape of what we register is testable
|
|
127
|
+
# ----------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def render_windows_task_xml(user: str | None = None) -> str:
|
|
131
|
+
"""Task Scheduler definition: start at logon, restart on failure, no window."""
|
|
132
|
+
executable, args, workdir = launch_command()
|
|
133
|
+
account = user or f"{os.environ.get('USERDOMAIN', '')}\\{os.environ.get('USERNAME', '')}"
|
|
134
|
+
arguments = " ".join(args)
|
|
135
|
+
return f"""<?xml version="1.0" encoding="UTF-16"?>
|
|
136
|
+
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
137
|
+
<RegistrationInfo>
|
|
138
|
+
<Description>Agentmetry orchestrator. Records AI agent tool-use to the local audit trail.</Description>
|
|
139
|
+
<URI>\\{TASK_NAME}</URI>
|
|
140
|
+
</RegistrationInfo>
|
|
141
|
+
<Triggers>
|
|
142
|
+
<LogonTrigger>
|
|
143
|
+
<Enabled>true</Enabled>
|
|
144
|
+
<UserId>{account}</UserId>
|
|
145
|
+
<Repetition>
|
|
146
|
+
<Interval>PT{RESTART_INTERVAL_MINUTES}M</Interval>
|
|
147
|
+
<StopAtDurationEnd>false</StopAtDurationEnd>
|
|
148
|
+
</Repetition>
|
|
149
|
+
</LogonTrigger>
|
|
150
|
+
<!-- The logon trigger's repetition only engages once a logon fires. Someone
|
|
151
|
+
who installs autostart mid-session would therefore have no keep-alive
|
|
152
|
+
until they next logged out, which is exactly the window where they
|
|
153
|
+
just decided they wanted one. This trigger starts in the past, so the
|
|
154
|
+
repeat is live the moment the task is registered. -->
|
|
155
|
+
<TimeTrigger>
|
|
156
|
+
<Enabled>true</Enabled>
|
|
157
|
+
<StartBoundary>{KEEPALIVE_EPOCH}</StartBoundary>
|
|
158
|
+
<Repetition>
|
|
159
|
+
<Interval>PT{RESTART_INTERVAL_MINUTES}M</Interval>
|
|
160
|
+
<StopAtDurationEnd>false</StopAtDurationEnd>
|
|
161
|
+
</Repetition>
|
|
162
|
+
</TimeTrigger>
|
|
163
|
+
</Triggers>
|
|
164
|
+
<Principals>
|
|
165
|
+
<Principal id="Author">
|
|
166
|
+
<UserId>{account}</UserId>
|
|
167
|
+
<LogonType>InteractiveToken</LogonType>
|
|
168
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
169
|
+
</Principal>
|
|
170
|
+
</Principals>
|
|
171
|
+
<Settings>
|
|
172
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
173
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
174
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
175
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
176
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
177
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
178
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
179
|
+
<Enabled>true</Enabled>
|
|
180
|
+
<Hidden>true</Hidden>
|
|
181
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
182
|
+
<!-- RestartOnFailure is not the keep-alive, despite the name. It covers a
|
|
183
|
+
task that fails to launch, not an action that starts fine and dies an
|
|
184
|
+
hour later. Verified: killing the recorder left LastTaskResult
|
|
185
|
+
0xFFFFFFFF and nothing restarted it. The repeating trigger above is
|
|
186
|
+
what actually brings it back, since IgnoreNew makes a repeat a no-op
|
|
187
|
+
while an instance is alive and a restart once it is not. This stays as
|
|
188
|
+
a second line for launch failures. -->
|
|
189
|
+
<RestartOnFailure>
|
|
190
|
+
<Interval>PT{RESTART_INTERVAL_MINUTES}M</Interval>
|
|
191
|
+
<Count>{RESTART_COUNT}</Count>
|
|
192
|
+
</RestartOnFailure>
|
|
193
|
+
</Settings>
|
|
194
|
+
<Actions Context="Author">
|
|
195
|
+
<Exec>
|
|
196
|
+
<Command>{executable}</Command>
|
|
197
|
+
<Arguments>{arguments}</Arguments>
|
|
198
|
+
<WorkingDirectory>{workdir}</WorkingDirectory>
|
|
199
|
+
</Exec>
|
|
200
|
+
</Actions>
|
|
201
|
+
</Task>
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def render_systemd_unit() -> str:
|
|
206
|
+
executable, args, workdir = launch_command()
|
|
207
|
+
exec_start = " ".join([executable, *args])
|
|
208
|
+
return f"""[Unit]
|
|
209
|
+
Description=Agentmetry orchestrator (records AI agent tool-use to the local audit trail)
|
|
210
|
+
After=network.target
|
|
211
|
+
|
|
212
|
+
[Service]
|
|
213
|
+
Type=simple
|
|
214
|
+
WorkingDirectory={workdir}
|
|
215
|
+
ExecStart={exec_start}
|
|
216
|
+
Restart=always
|
|
217
|
+
RestartSec={RESTART_INTERVAL_MINUTES * 60}
|
|
218
|
+
|
|
219
|
+
[Install]
|
|
220
|
+
WantedBy=default.target
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def render_launchd_plist() -> str:
|
|
225
|
+
executable, args, workdir = launch_command()
|
|
226
|
+
program_args = "\n".join(f" <string>{a}</string>" for a in [executable, *args])
|
|
227
|
+
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
228
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
229
|
+
<plist version="1.0">
|
|
230
|
+
<dict>
|
|
231
|
+
<key>Label</key><string>{LAUNCH_LABEL}</string>
|
|
232
|
+
<key>ProgramArguments</key>
|
|
233
|
+
<array>
|
|
234
|
+
{program_args}
|
|
235
|
+
</array>
|
|
236
|
+
<key>WorkingDirectory</key><string>{workdir}</string>
|
|
237
|
+
<key>RunAtLoad</key><true/>
|
|
238
|
+
<key>KeepAlive</key><true/>
|
|
239
|
+
</dict>
|
|
240
|
+
</plist>
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def systemd_unit_path() -> Path:
|
|
245
|
+
return Path.home() / ".config" / "systemd" / "user" / f"{SERVICE_NAME}.service"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def launchd_plist_path() -> Path:
|
|
249
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{LAUNCH_LABEL}.plist"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ----------------------------------------------------------------------
|
|
253
|
+
# Status — safe to call from doctor on any platform
|
|
254
|
+
# ----------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def status() -> AutostartStatus:
|
|
258
|
+
if os.name == "nt":
|
|
259
|
+
return _windows_status()
|
|
260
|
+
if sys.platform == "darwin":
|
|
261
|
+
return _launchd_status()
|
|
262
|
+
return _systemd_status()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _run(argv: list[str], timeout: int = 20) -> subprocess.CompletedProcess[str] | None:
|
|
266
|
+
"""Best-effort probe. A probe that raises is a probe that makes `doctor` worse."""
|
|
267
|
+
try:
|
|
268
|
+
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
|
269
|
+
except (OSError, subprocess.SubprocessError):
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_field(verbose_query: str, label: str) -> str | None:
|
|
274
|
+
"""Pull one labelled field out of `schtasks /V /FO LIST` output.
|
|
275
|
+
|
|
276
|
+
Parsed rather than requested as CSV because the column names are localized
|
|
277
|
+
on non-English Windows and the CSV header would be too; this at least fails
|
|
278
|
+
to `None` (unknown) rather than to a wrong answer.
|
|
279
|
+
"""
|
|
280
|
+
for line in verbose_query.splitlines():
|
|
281
|
+
name, sep, value = line.partition(":")
|
|
282
|
+
if sep and name.strip().lower() == label:
|
|
283
|
+
return value.strip()
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _parse_last_result(verbose_query: str) -> int | None:
|
|
288
|
+
text = _parse_field(verbose_query, "last result")
|
|
289
|
+
if text is None:
|
|
290
|
+
return None
|
|
291
|
+
try:
|
|
292
|
+
return int(text, 16) if text.lower().startswith("0x") else int(text)
|
|
293
|
+
except ValueError:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
#: Last-run codes that do not mean the recorder failed.
|
|
298
|
+
#:
|
|
299
|
+
#: 0x41303 is "has never run", the normal state between registering and the
|
|
300
|
+
#: first trigger. 0x41301 is "currently running". 0x800710E0 is Task Scheduler
|
|
301
|
+
#: declining to start a second instance because one is already alive, which is
|
|
302
|
+
#: `MultipleInstancesPolicy=IgnoreNew` doing its job.
|
|
303
|
+
_BENIGN_LAST_RESULTS = frozenset({0, 0x41303, 0x41301, -2147020576})
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _windows_status() -> AutostartStatus:
|
|
307
|
+
if shutil.which("schtasks") is None:
|
|
308
|
+
return AutostartStatus(False, "schtasks", "schtasks is not on PATH")
|
|
309
|
+
result = _run(["schtasks", "/Query", "/TN", TASK_NAME])
|
|
310
|
+
if result is None:
|
|
311
|
+
return AutostartStatus(False, "schtasks", "could not query Task Scheduler")
|
|
312
|
+
if result.returncode != 0:
|
|
313
|
+
return AutostartStatus(False, "schtasks", f"no scheduled task named '{TASK_NAME}'")
|
|
314
|
+
|
|
315
|
+
registered = f"scheduled task '{TASK_NAME}' is registered"
|
|
316
|
+
verbose = _run(["schtasks", "/Query", "/TN", TASK_NAME, "/V", "/FO", "LIST"])
|
|
317
|
+
if verbose is None or verbose.returncode != 0:
|
|
318
|
+
return AutostartStatus(True, "schtasks", registered)
|
|
319
|
+
|
|
320
|
+
last = _parse_last_result(verbose.stdout)
|
|
321
|
+
state = (_parse_field(verbose.stdout, "scheduled task state") or "").lower()
|
|
322
|
+
running = (_parse_field(verbose.stdout, "status") or "").lower() == "running"
|
|
323
|
+
|
|
324
|
+
if state == "disabled":
|
|
325
|
+
return AutostartStatus(
|
|
326
|
+
True,
|
|
327
|
+
"schtasks",
|
|
328
|
+
f"{registered} but disabled, so nothing will restart the recorder. "
|
|
329
|
+
"Re-enable it in Task Scheduler, or run `agentmetry install`.",
|
|
330
|
+
healthy=False,
|
|
331
|
+
last_result=last,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Status beats Last Result, because Last Result is not a health signal for a
|
|
335
|
+
# task that stays up. It records the last *launch attempt*, and every
|
|
336
|
+
# repeating trigger that fires while the recorder is alive is refused by
|
|
337
|
+
# IgnoreNew and recorded as a non-zero code. Reading that as a fault would
|
|
338
|
+
# mean a correctly working recorder reports broken almost all the time,
|
|
339
|
+
# which is a worse failure than the silence this check replaced: an alarm
|
|
340
|
+
# that is always on gets muted, and then it cannot warn about anything.
|
|
341
|
+
if running:
|
|
342
|
+
return AutostartStatus(True, "schtasks", registered, healthy=True, last_result=last)
|
|
343
|
+
|
|
344
|
+
if last is None:
|
|
345
|
+
return AutostartStatus(True, "schtasks", registered)
|
|
346
|
+
if last in _BENIGN_LAST_RESULTS:
|
|
347
|
+
return AutostartStatus(True, "schtasks", registered, healthy=True, last_result=last)
|
|
348
|
+
return AutostartStatus(
|
|
349
|
+
True,
|
|
350
|
+
"schtasks",
|
|
351
|
+
f"{registered}, but it is not running and its last run exited {last}. "
|
|
352
|
+
"It is registered and failing, so nothing is recording. "
|
|
353
|
+
"`agentmetry install` re-registers it with the current launch command.",
|
|
354
|
+
healthy=False,
|
|
355
|
+
last_result=last,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _systemd_status() -> AutostartStatus:
|
|
360
|
+
path = systemd_unit_path()
|
|
361
|
+
if not path.is_file():
|
|
362
|
+
return AutostartStatus(False, "systemd (user)", f"no unit at {path}")
|
|
363
|
+
if shutil.which("systemctl") is None:
|
|
364
|
+
return AutostartStatus(True, "systemd (user)", str(path))
|
|
365
|
+
result = _run(["systemctl", "--user", "is-active", f"{SERVICE_NAME}.service"])
|
|
366
|
+
if result is None:
|
|
367
|
+
return AutostartStatus(True, "systemd (user)", str(path))
|
|
368
|
+
state = result.stdout.strip() or "unknown"
|
|
369
|
+
if state == "active":
|
|
370
|
+
return AutostartStatus(True, "systemd (user)", str(path), healthy=True)
|
|
371
|
+
if state in ("activating", "reloading", "unknown"):
|
|
372
|
+
return AutostartStatus(True, "systemd (user)", f"{path} ({state})")
|
|
373
|
+
return AutostartStatus(
|
|
374
|
+
True,
|
|
375
|
+
"systemd (user)",
|
|
376
|
+
f"{path} is installed but the unit is {state}, so nothing is recording. "
|
|
377
|
+
"`systemctl --user status agentmetry` has the reason.",
|
|
378
|
+
healthy=False,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _launchd_status() -> AutostartStatus:
|
|
383
|
+
path = launchd_plist_path()
|
|
384
|
+
if not path.is_file():
|
|
385
|
+
return AutostartStatus(False, "launchd", f"no launch agent at {path}")
|
|
386
|
+
if shutil.which("launchctl") is None:
|
|
387
|
+
return AutostartStatus(True, "launchd", str(path))
|
|
388
|
+
result = _run(["launchctl", "list", LAUNCH_LABEL])
|
|
389
|
+
if result is None or result.returncode != 0:
|
|
390
|
+
return AutostartStatus(True, "launchd", str(path))
|
|
391
|
+
# `launchctl list <label>` prints a plist-ish dict; LastExitStatus is the
|
|
392
|
+
# field that distinguishes "loaded" from "loaded and working".
|
|
393
|
+
last: int | None = None
|
|
394
|
+
for line in result.stdout.splitlines():
|
|
395
|
+
if '"LastExitStatus"' in line:
|
|
396
|
+
digits = "".join(c for c in line.split("=")[-1] if c.isdigit() or c == "-")
|
|
397
|
+
last = int(digits) if digits.strip("-") else None
|
|
398
|
+
break
|
|
399
|
+
if last is None or last == 0:
|
|
400
|
+
return AutostartStatus(True, "launchd", str(path), healthy=last == 0, last_result=last)
|
|
401
|
+
return AutostartStatus(
|
|
402
|
+
True,
|
|
403
|
+
"launchd",
|
|
404
|
+
f"{path} is loaded but last exited {last}, so nothing is recording. "
|
|
405
|
+
"`agentmetry install` re-registers it with the current launch command.",
|
|
406
|
+
healthy=False,
|
|
407
|
+
last_result=last,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ----------------------------------------------------------------------
|
|
412
|
+
# Install / remove
|
|
413
|
+
# ----------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def install() -> tuple[bool, str]:
|
|
417
|
+
try:
|
|
418
|
+
launch_command()
|
|
419
|
+
except PackagedBuild as exc:
|
|
420
|
+
return False, str(exc)
|
|
421
|
+
if os.name == "nt":
|
|
422
|
+
return _install_windows()
|
|
423
|
+
if sys.platform == "darwin":
|
|
424
|
+
return _install_launchd()
|
|
425
|
+
return _install_systemd()
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def remove() -> tuple[bool, str]:
|
|
429
|
+
if os.name == "nt":
|
|
430
|
+
return _remove_windows()
|
|
431
|
+
if sys.platform == "darwin":
|
|
432
|
+
return _remove_launchd()
|
|
433
|
+
return _remove_systemd()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _install_windows() -> tuple[bool, str]:
|
|
437
|
+
import tempfile
|
|
438
|
+
|
|
439
|
+
xml = render_windows_task_xml()
|
|
440
|
+
# schtasks reads the definition as UTF-16, and silently misparses UTF-8.
|
|
441
|
+
handle = tempfile.NamedTemporaryFile(
|
|
442
|
+
"w", suffix=".xml", delete=False, encoding="utf-16"
|
|
443
|
+
)
|
|
444
|
+
try:
|
|
445
|
+
handle.write(xml)
|
|
446
|
+
handle.close()
|
|
447
|
+
result = subprocess.run(
|
|
448
|
+
["schtasks", "/Create", "/TN", TASK_NAME, "/XML", handle.name, "/F"],
|
|
449
|
+
capture_output=True,
|
|
450
|
+
text=True,
|
|
451
|
+
timeout=60,
|
|
452
|
+
)
|
|
453
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
454
|
+
return False, f"could not register the scheduled task: {exc}"
|
|
455
|
+
finally:
|
|
456
|
+
try:
|
|
457
|
+
os.unlink(handle.name)
|
|
458
|
+
except OSError:
|
|
459
|
+
pass
|
|
460
|
+
|
|
461
|
+
if result.returncode != 0:
|
|
462
|
+
return False, (result.stderr.strip() or result.stdout.strip() or "schtasks failed")
|
|
463
|
+
return True, (
|
|
464
|
+
f"Registered '{TASK_NAME}'. It starts at logon and is re-checked every "
|
|
465
|
+
f"{RESTART_INTERVAL_MINUTES}m, so a recorder that dies comes back within "
|
|
466
|
+
"about a minute."
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _remove_windows() -> tuple[bool, str]:
|
|
471
|
+
try:
|
|
472
|
+
result = subprocess.run(
|
|
473
|
+
["schtasks", "/Delete", "/TN", TASK_NAME, "/F"],
|
|
474
|
+
capture_output=True,
|
|
475
|
+
text=True,
|
|
476
|
+
timeout=30,
|
|
477
|
+
)
|
|
478
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
479
|
+
return False, f"could not remove the scheduled task: {exc}"
|
|
480
|
+
if result.returncode != 0:
|
|
481
|
+
return False, (result.stderr.strip() or result.stdout.strip() or "schtasks failed")
|
|
482
|
+
return True, f"Removed scheduled task '{TASK_NAME}'."
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _install_systemd() -> tuple[bool, str]:
|
|
486
|
+
if shutil.which("systemctl") is None:
|
|
487
|
+
return False, "systemctl not found; register the orchestrator with your init system by hand"
|
|
488
|
+
path = systemd_unit_path()
|
|
489
|
+
try:
|
|
490
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
path.write_text(render_systemd_unit(), encoding="utf-8")
|
|
492
|
+
except OSError as exc:
|
|
493
|
+
return False, f"could not write {path}: {exc}"
|
|
494
|
+
|
|
495
|
+
for argv in (
|
|
496
|
+
["systemctl", "--user", "daemon-reload"],
|
|
497
|
+
["systemctl", "--user", "enable", "--now", f"{SERVICE_NAME}.service"],
|
|
498
|
+
):
|
|
499
|
+
try:
|
|
500
|
+
result = subprocess.run(argv, capture_output=True, text=True, timeout=60)
|
|
501
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
502
|
+
return False, f"{' '.join(argv)} failed: {exc}"
|
|
503
|
+
if result.returncode != 0:
|
|
504
|
+
return False, (result.stderr.strip() or f"{' '.join(argv)} failed")
|
|
505
|
+
|
|
506
|
+
return True, (
|
|
507
|
+
f"Installed and started {SERVICE_NAME}.service ({path}). A user unit stops at "
|
|
508
|
+
"logout; run `loginctl enable-linger $USER` if the recorder should keep "
|
|
509
|
+
"running while you are logged out."
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _remove_systemd() -> tuple[bool, str]:
|
|
514
|
+
path = systemd_unit_path()
|
|
515
|
+
if shutil.which("systemctl") is not None:
|
|
516
|
+
subprocess.run(
|
|
517
|
+
["systemctl", "--user", "disable", "--now", f"{SERVICE_NAME}.service"],
|
|
518
|
+
capture_output=True,
|
|
519
|
+
text=True,
|
|
520
|
+
timeout=60,
|
|
521
|
+
)
|
|
522
|
+
try:
|
|
523
|
+
path.unlink(missing_ok=True)
|
|
524
|
+
except OSError as exc:
|
|
525
|
+
return False, f"could not remove {path}: {exc}"
|
|
526
|
+
return True, f"Removed {path}."
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _install_launchd() -> tuple[bool, str]:
|
|
530
|
+
path = launchd_plist_path()
|
|
531
|
+
try:
|
|
532
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
533
|
+
path.write_text(render_launchd_plist(), encoding="utf-8")
|
|
534
|
+
except OSError as exc:
|
|
535
|
+
return False, f"could not write {path}: {exc}"
|
|
536
|
+
try:
|
|
537
|
+
subprocess.run(
|
|
538
|
+
["launchctl", "load", "-w", str(path)],
|
|
539
|
+
capture_output=True,
|
|
540
|
+
text=True,
|
|
541
|
+
timeout=60,
|
|
542
|
+
)
|
|
543
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
544
|
+
return False, f"launchctl load failed: {exc}"
|
|
545
|
+
return True, f"Installed launch agent {LAUNCH_LABEL} ({path})."
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _remove_launchd() -> tuple[bool, str]:
|
|
549
|
+
path = launchd_plist_path()
|
|
550
|
+
try:
|
|
551
|
+
subprocess.run(
|
|
552
|
+
["launchctl", "unload", "-w", str(path)],
|
|
553
|
+
capture_output=True,
|
|
554
|
+
text=True,
|
|
555
|
+
timeout=60,
|
|
556
|
+
)
|
|
557
|
+
except (OSError, subprocess.SubprocessError):
|
|
558
|
+
pass
|
|
559
|
+
try:
|
|
560
|
+
path.unlink(missing_ok=True)
|
|
561
|
+
except OSError as exc:
|
|
562
|
+
return False, f"could not remove {path}: {exc}"
|
|
563
|
+
return True, f"Removed launch agent {LAUNCH_LABEL}."
|