agentic-runner 2.6.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.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
"""The workstation Runner: one login agent per Organisation (PRD issue 47, map ticket 23).
|
|
2
|
+
|
|
3
|
+
``agentic-runner install | start | stop | status <org>``. Everything a CI-runner
|
|
4
|
+
precedent does on a developer machine and nothing a daemon would need:
|
|
5
|
+
|
|
6
|
+
* **One process per Organisation** (23 item 3), each with its own state directory,
|
|
7
|
+
identity, namespace, Runner Token, hooks, credential store and Workspaces under
|
|
8
|
+
``<root>/<org>/``. A contractor with two clients runs two. There is no supervisor over
|
|
9
|
+
N Organisations -- that is the "one Runner straddling Organisations" 12 A6 rejected.
|
|
10
|
+
* **A login agent, never a system daemon** (23 item 1): a LaunchAgent in
|
|
11
|
+
``~/Library/LaunchAgents`` on macOS, a systemd *user* unit (with linger) on Linux, a
|
|
12
|
+
per-user logon task on Windows. No ``sudo`` anywhere: the login keychain is unreachable
|
|
13
|
+
from a daemon (Apple TN3137), and a Runner that needed root would be the wrong shape.
|
|
14
|
+
No self-update in Release 1 -- the control plane's version floor (issue 41) is the lever.
|
|
15
|
+
* **Native process, CLIs found not bundled** (23 item 2): ``codex`` and ``claude`` are
|
|
16
|
+
looked up on the ``PATH`` captured at install, their versions reported on the heartbeat,
|
|
17
|
+
and a missing CLI the Profile names refuses the start. The Runner never opens a file
|
|
18
|
+
under a harness root -- the per-Contract ``CODEX_HOME`` / ``CLAUDE_CONFIG_DIR`` belongs
|
|
19
|
+
to the login the funder created there (issues 30, 31).
|
|
20
|
+
* **``isolation: none``** (17 A2): no root and no uid switch, so the control plane routes
|
|
21
|
+
at most one Contract at a time here (issue 42), which one Organisation per process and
|
|
22
|
+
one active Contract per (Organisation, User Profile) pair already implied.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import os
|
|
29
|
+
import platform as platform_module
|
|
30
|
+
import plistlib
|
|
31
|
+
import re
|
|
32
|
+
import shutil
|
|
33
|
+
import subprocess
|
|
34
|
+
import sys
|
|
35
|
+
import tempfile
|
|
36
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from datetime import UTC, datetime
|
|
39
|
+
from hashlib import sha256
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Final
|
|
42
|
+
from xml.sax.saxutils import escape
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
from pydantic import BaseModel, ConfigDict
|
|
46
|
+
|
|
47
|
+
import agentic_runner
|
|
48
|
+
from agentic_runner import service
|
|
49
|
+
from agentic_runner.host_store import open_workstation_store
|
|
50
|
+
from agentic_runner.lifecycle import LifecycleOutbox
|
|
51
|
+
from agentic_runner.registration import load_state
|
|
52
|
+
from agentic_runner.service import HEARTBEAT_STAMP_NAME, PIDFILE_NAME
|
|
53
|
+
from agentic_runner_contracts.runner_registration import (
|
|
54
|
+
CliVersion,
|
|
55
|
+
EgressPosture,
|
|
56
|
+
HostAttestation,
|
|
57
|
+
InstallChannel,
|
|
58
|
+
IsolationMode,
|
|
59
|
+
LifecycleKind,
|
|
60
|
+
SessionKind,
|
|
61
|
+
StoreKind,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"CLI_FLOORS",
|
|
66
|
+
"CLI_NAMES",
|
|
67
|
+
"ROOT_ENV",
|
|
68
|
+
"CliMissingError",
|
|
69
|
+
"OrgPaths",
|
|
70
|
+
"ServiceDefinition",
|
|
71
|
+
"WorkstationSettings",
|
|
72
|
+
"cli_versions",
|
|
73
|
+
"collect_attestation",
|
|
74
|
+
"default_root",
|
|
75
|
+
"install",
|
|
76
|
+
"install_channel",
|
|
77
|
+
"locate_clis",
|
|
78
|
+
"require_any_cli",
|
|
79
|
+
"run_environment",
|
|
80
|
+
"run_process",
|
|
81
|
+
"service_definition",
|
|
82
|
+
"start",
|
|
83
|
+
"status_lines",
|
|
84
|
+
"stop",
|
|
85
|
+
"validate_org",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
ROOT_ENV: Final = "AGENTIC_RUNNER_WORKSTATION_ROOT"
|
|
89
|
+
|
|
90
|
+
# What the Profile's `cli_kind` runs, by executable name on PATH.
|
|
91
|
+
CLI_NAMES: Final[Mapping[str, str]] = {"codex_cli": "codex", "claude_code": "claude"}
|
|
92
|
+
# The versions the Runner image is built and tested against (Dockerfile.runner's
|
|
93
|
+
# CODEX_CLI_VERSION / CLAUDE_CODE_VERSION; a test holds the two together). Below the
|
|
94
|
+
# floor is *reported*, not refused: the user's own CLI is the user's to upgrade.
|
|
95
|
+
CLI_FLOORS: Final[Mapping[str, str]] = {"codex": "0.141.0", "claude": "2.1.280"}
|
|
96
|
+
|
|
97
|
+
_ORG = re.compile(r"^[a-z0-9][a-z0-9-]{0,31}$")
|
|
98
|
+
_VERSION = re.compile(r"(\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,40})")
|
|
99
|
+
_LABEL_PREFIX: Final = "agentic-runner"
|
|
100
|
+
|
|
101
|
+
Run = Callable[..., subprocess.CompletedProcess[str]]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CliMissingError(RuntimeError):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_org(org: str) -> str:
|
|
109
|
+
"""One path segment, one launchd label, one systemd instance name -- all at once."""
|
|
110
|
+
|
|
111
|
+
if not _ORG.fullmatch(org):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Organisation name {org!r} must be 1-32 of lowercase letters, digits and '-'"
|
|
114
|
+
)
|
|
115
|
+
return org
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def default_root(*, platform: str = sys.platform, environ: Mapping[str, str] = os.environ) -> Path:
|
|
119
|
+
"""Where every Organisation's directory lives, per the OS's per-user convention."""
|
|
120
|
+
|
|
121
|
+
if environ.get(ROOT_ENV, "").strip():
|
|
122
|
+
return Path(environ[ROOT_ENV])
|
|
123
|
+
home = Path(environ.get("HOME") or Path.home())
|
|
124
|
+
if platform == "darwin":
|
|
125
|
+
return home / "Library" / "Application Support" / "agentic-runner"
|
|
126
|
+
if platform == "win32":
|
|
127
|
+
return Path(environ.get("LOCALAPPDATA") or home / "AppData" / "Local") / "agentic-runner"
|
|
128
|
+
state_home = environ.get("XDG_STATE_HOME", "").strip()
|
|
129
|
+
return (Path(state_home) if state_home else home / ".local" / "state") / "agentic-runner"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class OrgPaths:
|
|
134
|
+
"""``<root>/<org>/`` and everything the process keeps under it."""
|
|
135
|
+
|
|
136
|
+
root: Path
|
|
137
|
+
org: str
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def state_dir(self) -> Path:
|
|
141
|
+
return self.root / self.org
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def hooks(self) -> Path:
|
|
145
|
+
# Issue 45's path, per map ticket 26: `<state_dir>/<org>/hooks/`.
|
|
146
|
+
return self.state_dir / "hooks"
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def workspaces(self) -> Path:
|
|
150
|
+
# Issue 30's `{contract_id}/{work_record_id}` layout sits under here, and the
|
|
151
|
+
# per-Contract harness roots with it (`{contract_id}/harness/{runtime_kind}`).
|
|
152
|
+
return self.state_dir / "workspaces"
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def contract_state(self) -> Path:
|
|
156
|
+
return self.state_dir / "contracts"
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def credentials(self) -> Path:
|
|
160
|
+
return self.state_dir / "credentials"
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def settings_file(self) -> Path:
|
|
164
|
+
return self.state_dir / "workstation.json"
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def pidfile(self) -> Path:
|
|
168
|
+
return self.state_dir / PIDFILE_NAME
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def heartbeat_stamp(self) -> Path:
|
|
172
|
+
return self.state_dir / HEARTBEAT_STAMP_NAME
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def log(self) -> Path:
|
|
176
|
+
return self.state_dir / "runner.log"
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def socket_dir(self) -> Path:
|
|
180
|
+
# `sun_path` is ~104 bytes and "~/Library/Application Support/..." is most of
|
|
181
|
+
# that already, so the per-attempt sockets get a short *per-user* root: the
|
|
182
|
+
# session's runtime dir, or macOS's per-user `$TMPDIR` -- never a shared `/tmp`
|
|
183
|
+
# another user could pre-create the directory in.
|
|
184
|
+
runtime = os.environ.get("XDG_RUNTIME_DIR", "").strip() or (
|
|
185
|
+
tempfile.gettempdir() if sys.platform == "darwin" else ""
|
|
186
|
+
)
|
|
187
|
+
if runtime:
|
|
188
|
+
return Path(runtime) / f"agentic-runner-{self.org}"
|
|
189
|
+
return self.state_dir / "sockets"
|
|
190
|
+
|
|
191
|
+
def create(self) -> None:
|
|
192
|
+
for directory in (self.state_dir, self.hooks, self.workspaces, self.contract_state):
|
|
193
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
# The identity's private key and the fallback credential files are in here.
|
|
195
|
+
self.state_dir.chmod(0o700)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class WorkstationSettings(BaseModel):
|
|
199
|
+
"""What ``install`` was told, so ``run`` and ``status`` need no flags."""
|
|
200
|
+
|
|
201
|
+
# `ignore`, not `forbid`: only `save` writes this file, and an install made before
|
|
202
|
+
# local-agents 01 retired the per-process `cli_kind` still has to `run` and `status`.
|
|
203
|
+
model_config = ConfigDict(extra="ignore", frozen=True)
|
|
204
|
+
|
|
205
|
+
org: str
|
|
206
|
+
control_plane_url: str
|
|
207
|
+
temporal_address: str
|
|
208
|
+
# `temporal-grpc.<zone>` over TLS with the Runner Token (15's note on 23): no VPN, no
|
|
209
|
+
# client certificate. Plaintext exists for a local dev server only.
|
|
210
|
+
temporal_tls: bool = True
|
|
211
|
+
# The PATH `install` ran under: a LaunchAgent starts with `/usr/bin:/bin:...` and
|
|
212
|
+
# would not find a Homebrew or `~/.local/bin` CLI without it.
|
|
213
|
+
path: str
|
|
214
|
+
install_channel: InstallChannel
|
|
215
|
+
|
|
216
|
+
def save(self, paths: OrgPaths) -> None:
|
|
217
|
+
paths.settings_file.write_text(self.model_dump_json(indent=2), encoding="utf-8")
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def load(cls, paths: OrgPaths) -> WorkstationSettings:
|
|
221
|
+
if not paths.settings_file.is_file():
|
|
222
|
+
raise FileNotFoundError(
|
|
223
|
+
f"no Runner is installed for {paths.org!r} under {paths.root}; "
|
|
224
|
+
f"run `agentic-runner install {paths.org}` first"
|
|
225
|
+
)
|
|
226
|
+
return cls.model_validate_json(paths.settings_file.read_text(encoding="utf-8"))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def install_channel(
|
|
230
|
+
platform: str = sys.platform, *, executable: str = sys.executable
|
|
231
|
+
) -> InstallChannel:
|
|
232
|
+
if platform == "darwin":
|
|
233
|
+
return InstallChannel.HOMEBREW if "/Cellar/" in executable else InstallChannel.MANUAL
|
|
234
|
+
if platform == "win32":
|
|
235
|
+
return InstallChannel.WINDOWS_USER
|
|
236
|
+
return InstallChannel.SYSTEMD_USER
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def run_environment(paths: OrgPaths, settings: WorkstationSettings) -> dict[str, str]:
|
|
240
|
+
"""The environment ``agentic-runner run --org`` gives ``service.run``.
|
|
241
|
+
|
|
242
|
+
Set here rather than in the service definition so one definition format -- a plist,
|
|
243
|
+
a unit, a task -- stays a one-liner, and the three OSes cannot drift apart.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"PATH": settings.path,
|
|
248
|
+
"AGENTIC_RUNNER_STATE_DIR": str(paths.state_dir),
|
|
249
|
+
"AGENTIC_RUNNER_WORKSPACE_ROOT": str(paths.workspaces),
|
|
250
|
+
"AGENTIC_RUNNER_HOOKS_PATH": str(paths.hooks),
|
|
251
|
+
"AGENTIC_RUNNER_SOCKET_DIR": str(paths.socket_dir),
|
|
252
|
+
"AGENTIC_RUNNER_ISOLATION": "none",
|
|
253
|
+
# A laptop has nothing beneath the attempt's egress proxy to stop a raw socket,
|
|
254
|
+
# so it reports the Profile's allow-list as unenforced (PRD issue 58).
|
|
255
|
+
service.EGRESS_POSTURE_ENV: EgressPosture.UNENFORCED.value,
|
|
256
|
+
"AGENTIC_CONTROL_PLANE_URL": settings.control_plane_url,
|
|
257
|
+
"AGENTIC_TEMPORAL_TLS": "1" if settings.temporal_tls else "0",
|
|
258
|
+
"TEMPORAL_ADDRESS": settings.temporal_address,
|
|
259
|
+
"WORKSPACE_ROOT": str(paths.workspaces),
|
|
260
|
+
"WORKER_STATE_DIR": str(paths.contract_state),
|
|
261
|
+
# Ephemeral: one process per Organisation shares the host, so a fixed readiness
|
|
262
|
+
# port inherited from a pod-style environment would collide on the second one.
|
|
263
|
+
service.READINESS_PORT_ENV: "0",
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ------------------------------------------------------------------ the CLIs
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def locate_clis(path: str) -> dict[str, str]:
|
|
271
|
+
"""``{"codex": "/opt/homebrew/bin/codex", ...}`` for each CLI found on ``path``."""
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
name: found
|
|
275
|
+
for name in CLI_NAMES.values()
|
|
276
|
+
if (found := shutil.which(name, path=path)) is not None
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def require_any_cli(path: str) -> dict[str, str]:
|
|
281
|
+
"""The CLIs on ``path``, or a refusal that says what to install.
|
|
282
|
+
|
|
283
|
+
One is enough: the Runner serves every Agent Runtime it finds and is routed only the
|
|
284
|
+
Work Records whose ``cli_kind`` it reports (local-agents 01), so a missing CLI costs
|
|
285
|
+
that runtime, never the start.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
found = locate_clis(path)
|
|
289
|
+
if not found:
|
|
290
|
+
raise CliMissingError(
|
|
291
|
+
f"none of {sorted(CLI_NAMES.values())} is on PATH ({path}). Install one and "
|
|
292
|
+
"log in, then run `agentic-runner install` again so the new PATH is captured"
|
|
293
|
+
)
|
|
294
|
+
return found
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _version_tuple(version: str) -> tuple[int, ...]:
|
|
298
|
+
return tuple(int(part) for part in re.findall(r"\d+", version)[:3])
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def cli_versions(found: Mapping[str, str], *, run: Run = subprocess.run) -> list[CliVersion]:
|
|
302
|
+
"""Each CLI's ``--version``, the only thing the Runner asks a CLI about itself.
|
|
303
|
+
|
|
304
|
+
Keyed by the Profile's ``cli_kind`` (``codex_cli``), not the executable: it is what
|
|
305
|
+
the Runner's registry is built from and what routing matches a Work Record on.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
versions: list[CliVersion] = []
|
|
309
|
+
for cli_kind, name in sorted(CLI_NAMES.items()):
|
|
310
|
+
executable = found.get(name)
|
|
311
|
+
if executable is None:
|
|
312
|
+
continue
|
|
313
|
+
try:
|
|
314
|
+
answer = run(
|
|
315
|
+
[executable, "--version"], capture_output=True, text=True, timeout=10, check=False
|
|
316
|
+
)
|
|
317
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
318
|
+
continue
|
|
319
|
+
match = _VERSION.search(answer.stdout or answer.stderr)
|
|
320
|
+
if match is None:
|
|
321
|
+
continue
|
|
322
|
+
version = match.group(1)
|
|
323
|
+
versions.append(
|
|
324
|
+
CliVersion(
|
|
325
|
+
cli_kind=cli_kind,
|
|
326
|
+
version=version,
|
|
327
|
+
meets_floor=_version_tuple(version) >= _version_tuple(CLI_FLOORS[name]),
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
return versions
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ------------------------------------------------------------------ attestation
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def build_id() -> str:
|
|
337
|
+
"""A digest of this build's own source: self-reported build identity (12 B7).
|
|
338
|
+
|
|
339
|
+
Not a signature and not claimed to be one -- the consoles say "self-reported" beside
|
|
340
|
+
it. What it buys is that two Runners reporting the same version but running
|
|
341
|
+
different code show different ids.
|
|
342
|
+
"""
|
|
343
|
+
|
|
344
|
+
package = Path(agentic_runner.__file__).parent
|
|
345
|
+
digest = sha256()
|
|
346
|
+
for source in sorted(package.rglob("*.py")):
|
|
347
|
+
digest.update(source.relative_to(package).as_posix().encode())
|
|
348
|
+
digest.update(source.read_bytes())
|
|
349
|
+
return digest.hexdigest()[:16]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def os_release() -> str:
|
|
353
|
+
system = re.sub(r"[^a-z0-9_]", "", platform_module.system().lower())[:16] or "unknown"
|
|
354
|
+
release = re.sub(r"[^0-9A-Za-z._+-]", "", platform_module.release())[:48]
|
|
355
|
+
return f"{system} {release}" if release else system
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def collect_attestation(
|
|
359
|
+
*,
|
|
360
|
+
channel: InstallChannel,
|
|
361
|
+
session: SessionKind,
|
|
362
|
+
store: StoreKind,
|
|
363
|
+
path: str,
|
|
364
|
+
run: Run = subprocess.run,
|
|
365
|
+
) -> HostAttestation:
|
|
366
|
+
"""Collected once at start-up: none of it changes while the process lives."""
|
|
367
|
+
|
|
368
|
+
return HostAttestation(
|
|
369
|
+
build_id=build_id(),
|
|
370
|
+
install_channel=channel,
|
|
371
|
+
os=os_release(),
|
|
372
|
+
session_kind=session,
|
|
373
|
+
store_kind=store,
|
|
374
|
+
clis=cli_versions(locate_clis(path), run=run),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# ------------------------------------------------------------------ service definitions
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@dataclass(frozen=True)
|
|
382
|
+
class ServiceDefinition:
|
|
383
|
+
"""One OS's per-user service: the file it installs and the commands that drive it."""
|
|
384
|
+
|
|
385
|
+
path: Path
|
|
386
|
+
content: bytes
|
|
387
|
+
install: list[list[str]]
|
|
388
|
+
start: list[list[str]]
|
|
389
|
+
stop: list[list[str]]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def runner_command(org: str, root: Path) -> list[str]:
|
|
393
|
+
# The console script as found, not resolved: Homebrew's `bin/agentic-runner` is a
|
|
394
|
+
# symlink into a versioned Cellar path that the next `brew upgrade` deletes.
|
|
395
|
+
script = shutil.which("agentic-runner")
|
|
396
|
+
prefix = [script] if script else [sys.executable, "-m", "agentic_runner.cli"]
|
|
397
|
+
return [*prefix, "run", "--org", org, "--root", str(root), "--login-agent"]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def service_definition(
|
|
401
|
+
paths: OrgPaths,
|
|
402
|
+
settings: WorkstationSettings,
|
|
403
|
+
*,
|
|
404
|
+
platform: str = sys.platform,
|
|
405
|
+
home: Path | None = None,
|
|
406
|
+
user: str | None = None,
|
|
407
|
+
uid: int | None = None,
|
|
408
|
+
command: Sequence[str] | None = None,
|
|
409
|
+
) -> ServiceDefinition:
|
|
410
|
+
home = home or Path.home()
|
|
411
|
+
argv = list(command or runner_command(paths.org, paths.root))
|
|
412
|
+
name = f"{_LABEL_PREFIX}.{paths.org}"
|
|
413
|
+
if platform == "darwin":
|
|
414
|
+
return _launch_agent(paths, settings, argv, name=name, home=home, uid=uid)
|
|
415
|
+
if platform == "win32":
|
|
416
|
+
return _logon_task(paths, argv, name=name, user=user)
|
|
417
|
+
return _systemd_user_unit(paths, settings, argv, home=home, user=user)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _launch_agent(
|
|
421
|
+
paths: OrgPaths,
|
|
422
|
+
settings: WorkstationSettings,
|
|
423
|
+
argv: list[str],
|
|
424
|
+
*,
|
|
425
|
+
name: str,
|
|
426
|
+
home: Path,
|
|
427
|
+
uid: int | None,
|
|
428
|
+
) -> ServiceDefinition:
|
|
429
|
+
plist = home / "Library" / "LaunchAgents" / f"{name}.plist"
|
|
430
|
+
domain = f"gui/{os.getuid() if uid is None else uid}"
|
|
431
|
+
content = plistlib.dumps(
|
|
432
|
+
{
|
|
433
|
+
"Label": name,
|
|
434
|
+
"ProgramArguments": argv,
|
|
435
|
+
"EnvironmentVariables": {"PATH": settings.path},
|
|
436
|
+
"RunAtLoad": True,
|
|
437
|
+
# Restart a crash, not a clean `stop`: SIGTERM drains and exits 0.
|
|
438
|
+
"KeepAlive": {"SuccessfulExit": False},
|
|
439
|
+
"ProcessType": "Background",
|
|
440
|
+
"StandardOutPath": str(paths.log),
|
|
441
|
+
"StandardErrorPath": str(paths.log),
|
|
442
|
+
}
|
|
443
|
+
)
|
|
444
|
+
return ServiceDefinition(
|
|
445
|
+
path=plist,
|
|
446
|
+
content=content,
|
|
447
|
+
install=[],
|
|
448
|
+
start=[["launchctl", "bootstrap", domain, str(plist)]],
|
|
449
|
+
stop=[["launchctl", "bootout", f"{domain}/{name}"]],
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _systemd_user_unit(
|
|
454
|
+
paths: OrgPaths,
|
|
455
|
+
settings: WorkstationSettings,
|
|
456
|
+
argv: list[str],
|
|
457
|
+
*,
|
|
458
|
+
home: Path,
|
|
459
|
+
user: str | None,
|
|
460
|
+
) -> ServiceDefinition:
|
|
461
|
+
unit_name = f"{_LABEL_PREFIX}-{paths.org}.service"
|
|
462
|
+
unit = home / ".config" / "systemd" / "user" / unit_name
|
|
463
|
+
exec_start = " ".join(_systemd_quote(arg) for arg in argv)
|
|
464
|
+
content = (
|
|
465
|
+
"[Unit]\n"
|
|
466
|
+
f"Description=Agentic OS Runner for {paths.org}\n"
|
|
467
|
+
"After=network-online.target\n\n"
|
|
468
|
+
"[Service]\n"
|
|
469
|
+
f"ExecStart={exec_start}\n"
|
|
470
|
+
f"Environment={_systemd_quote('PATH=' + settings.path)}\n"
|
|
471
|
+
"Restart=on-failure\n"
|
|
472
|
+
"RestartSec=10\n"
|
|
473
|
+
# The drain waits out an in-flight Directive (issue 02); systemd's default 90 s
|
|
474
|
+
# would SIGKILL it.
|
|
475
|
+
"TimeoutStopSec=2100\n"
|
|
476
|
+
"KillMode=mixed\n\n"
|
|
477
|
+
"[Install]\n"
|
|
478
|
+
"WantedBy=default.target\n"
|
|
479
|
+
).encode()
|
|
480
|
+
systemctl = ["systemctl", "--user"]
|
|
481
|
+
return ServiceDefinition(
|
|
482
|
+
path=unit,
|
|
483
|
+
content=content,
|
|
484
|
+
# Linger keeps a user's units running with nobody logged in -- a reboot of an
|
|
485
|
+
# unattended workstation -- and needs no root for one's own user.
|
|
486
|
+
install=[
|
|
487
|
+
[*systemctl, "daemon-reload"],
|
|
488
|
+
["loginctl", "enable-linger", user or os.environ.get("USER", "")],
|
|
489
|
+
],
|
|
490
|
+
start=[[*systemctl, "enable", "--now", unit_name]],
|
|
491
|
+
stop=[[*systemctl, "disable", "--now", unit_name]],
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _systemd_quote(value: str) -> str:
|
|
496
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _logon_task(
|
|
500
|
+
paths: OrgPaths, argv: list[str], *, name: str, user: str | None
|
|
501
|
+
) -> ServiceDefinition:
|
|
502
|
+
task_xml = paths.state_dir / f"{name}.task.xml"
|
|
503
|
+
task_name = f"\\agentic-runner\\{paths.org}"
|
|
504
|
+
who = user or os.environ.get("USERNAME", "")
|
|
505
|
+
arguments = subprocess.list2cmdline(argv[1:])
|
|
506
|
+
content = f"""<?xml version="1.0" encoding="UTF-16"?>
|
|
507
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
508
|
+
<RegistrationInfo>
|
|
509
|
+
<Description>Agentic OS Runner for {escape(paths.org)}</Description>
|
|
510
|
+
</RegistrationInfo>
|
|
511
|
+
<Triggers>
|
|
512
|
+
<LogonTrigger><Enabled>true</Enabled><UserId>{escape(who)}</UserId></LogonTrigger>
|
|
513
|
+
</Triggers>
|
|
514
|
+
<Principals>
|
|
515
|
+
<Principal id="Author">
|
|
516
|
+
<UserId>{escape(who)}</UserId>
|
|
517
|
+
<LogonType>InteractiveToken</LogonType>
|
|
518
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
519
|
+
</Principal>
|
|
520
|
+
</Principals>
|
|
521
|
+
<Settings>
|
|
522
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
523
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
524
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
525
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
526
|
+
<RestartOnFailure><Interval>PT1M</Interval><Count>999</Count></RestartOnFailure>
|
|
527
|
+
</Settings>
|
|
528
|
+
<Actions Context="Author">
|
|
529
|
+
<Exec><Command>{escape(argv[0])}</Command><Arguments>{escape(arguments)}</Arguments></Exec>
|
|
530
|
+
</Actions>
|
|
531
|
+
</Task>
|
|
532
|
+
""".encode("utf-16")
|
|
533
|
+
return ServiceDefinition(
|
|
534
|
+
path=task_xml,
|
|
535
|
+
content=content,
|
|
536
|
+
install=[["schtasks", "/Create", "/TN", task_name, "/XML", str(task_xml), "/F"]],
|
|
537
|
+
start=[
|
|
538
|
+
["schtasks", "/Change", "/TN", task_name, "/ENABLE"],
|
|
539
|
+
["schtasks", "/Run", "/TN", task_name],
|
|
540
|
+
],
|
|
541
|
+
stop=[
|
|
542
|
+
["schtasks", "/End", "/TN", task_name],
|
|
543
|
+
["schtasks", "/Change", "/TN", task_name, "/DISABLE"],
|
|
544
|
+
],
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def drive(commands: list[list[str]], *, run: Run = subprocess.run) -> None:
|
|
549
|
+
for command in commands:
|
|
550
|
+
done = run(command, capture_output=True, text=True, check=False)
|
|
551
|
+
if done.returncode != 0:
|
|
552
|
+
raise RuntimeError(
|
|
553
|
+
f"`{' '.join(command)}` exited {done.returncode}: {done.stderr.strip()}"
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
# ------------------------------------------------------------------ status
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def process_id(paths: OrgPaths) -> int | None:
|
|
561
|
+
"""The running process's pid, if the pidfile names a live one."""
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
pid = int(paths.pidfile.read_text(encoding="utf-8").strip())
|
|
565
|
+
except (OSError, ValueError):
|
|
566
|
+
return None
|
|
567
|
+
try:
|
|
568
|
+
os.kill(pid, 0)
|
|
569
|
+
except ProcessLookupError:
|
|
570
|
+
return None
|
|
571
|
+
except PermissionError:
|
|
572
|
+
return pid
|
|
573
|
+
return pid
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def heartbeat_age(paths: OrgPaths, *, now: datetime | None = None) -> float | None:
|
|
577
|
+
try:
|
|
578
|
+
stamped = paths.heartbeat_stamp.stat().st_mtime
|
|
579
|
+
except OSError:
|
|
580
|
+
return None
|
|
581
|
+
return max(0.0, (now or datetime.now(UTC)).timestamp() - stamped)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def status_lines(paths: OrgPaths, *, now: datetime | None = None) -> list[str]:
|
|
585
|
+
"""``status <org>``: the process, its identity and its heartbeat age."""
|
|
586
|
+
|
|
587
|
+
pid = process_id(paths)
|
|
588
|
+
state = load_state(paths.state_dir)
|
|
589
|
+
age = heartbeat_age(paths, now=now)
|
|
590
|
+
return [
|
|
591
|
+
f"organisation {paths.org} ({paths.state_dir})",
|
|
592
|
+
f"process {'running, pid ' + str(pid) if pid else 'not running'}",
|
|
593
|
+
(
|
|
594
|
+
f"identity {state.runner_id} in {state.temporal_namespace} on {state.task_queue}"
|
|
595
|
+
if state is not None
|
|
596
|
+
else "identity not registered"
|
|
597
|
+
),
|
|
598
|
+
f"heartbeat {'never' if age is None else f'{age:.0f}s ago'}",
|
|
599
|
+
]
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ------------------------------------------------------------------ the verbs
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def install(
|
|
606
|
+
paths: OrgPaths,
|
|
607
|
+
settings: WorkstationSettings,
|
|
608
|
+
*,
|
|
609
|
+
agent_token: str,
|
|
610
|
+
platform: str = sys.platform,
|
|
611
|
+
home: Path | None = None,
|
|
612
|
+
start_service: bool = True,
|
|
613
|
+
run: Run = subprocess.run,
|
|
614
|
+
http_client: httpx.AsyncClient | None = None,
|
|
615
|
+
) -> list[str]:
|
|
616
|
+
"""Register this Organisation's process and install its login agent.
|
|
617
|
+
|
|
618
|
+
Registration happens here, once, with the Agent Token the Organisation's Admin issued
|
|
619
|
+
to this user (12 A6) -- pasted at the prompt, never stored: the identity bootstrap
|
|
620
|
+
hands back is what every later start uses, and a lost state directory means a
|
|
621
|
+
reinstall and a re-delivery of any sealed value (22 A4), by design.
|
|
622
|
+
"""
|
|
623
|
+
|
|
624
|
+
require_any_cli(settings.path)
|
|
625
|
+
paths.create()
|
|
626
|
+
settings.save(paths)
|
|
627
|
+
state, outcome = await service.register(
|
|
628
|
+
state_dir=paths.state_dir,
|
|
629
|
+
can_separate_uids=False,
|
|
630
|
+
client=http_client,
|
|
631
|
+
control_plane=settings.control_plane_url,
|
|
632
|
+
agent_token=agent_token,
|
|
633
|
+
isolation=IsolationMode.NONE,
|
|
634
|
+
)
|
|
635
|
+
LifecycleOutbox(paths.state_dir).add(LifecycleKind.INSTALL, datetime.now(UTC))
|
|
636
|
+
definition = service_definition(paths, settings, platform=platform, home=home)
|
|
637
|
+
definition.path.parent.mkdir(parents=True, exist_ok=True)
|
|
638
|
+
definition.path.write_bytes(definition.content)
|
|
639
|
+
lines = [outcome, f"login agent {definition.path}"]
|
|
640
|
+
if start_service:
|
|
641
|
+
drive(definition.install + definition.start, run=run)
|
|
642
|
+
lines.append(f"started {state.runner_id}")
|
|
643
|
+
return lines
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def start(paths: OrgPaths, **driver: Any) -> None:
|
|
647
|
+
_drive_service(paths, "start", **driver)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def stop(paths: OrgPaths, **driver: Any) -> None:
|
|
651
|
+
"""Stop this Organisation's process; every other Organisation's keeps polling."""
|
|
652
|
+
|
|
653
|
+
_drive_service(paths, "stop", **driver)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _drive_service(
|
|
657
|
+
paths: OrgPaths,
|
|
658
|
+
verb: str,
|
|
659
|
+
*,
|
|
660
|
+
platform: str = sys.platform,
|
|
661
|
+
home: Path | None = None,
|
|
662
|
+
run: Run = subprocess.run,
|
|
663
|
+
) -> None:
|
|
664
|
+
settings = WorkstationSettings.load(paths)
|
|
665
|
+
definition = service_definition(paths, settings, platform=platform, home=home)
|
|
666
|
+
drive(definition.start if verb == "start" else definition.stop, run=run)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def run_process(paths: OrgPaths, *, login_agent: bool) -> int:
|
|
670
|
+
"""``agentic-runner run --org``: what the login agent executes."""
|
|
671
|
+
|
|
672
|
+
settings = WorkstationSettings.load(paths)
|
|
673
|
+
try:
|
|
674
|
+
require_any_cli(settings.path)
|
|
675
|
+
except CliMissingError as error:
|
|
676
|
+
print(f"refusing to start (cli_missing): {error}", flush=True)
|
|
677
|
+
return 1
|
|
678
|
+
for name, value in run_environment(paths, settings).items():
|
|
679
|
+
os.environ[name] = value
|
|
680
|
+
store = open_workstation_store(paths.org, fallback=paths.credentials)
|
|
681
|
+
attestation = collect_attestation(
|
|
682
|
+
channel=settings.install_channel,
|
|
683
|
+
session=SessionKind.LOGIN_AGENT if login_agent else SessionKind.INTERACTIVE,
|
|
684
|
+
store=store.kind,
|
|
685
|
+
path=settings.path,
|
|
686
|
+
)
|
|
687
|
+
return asyncio.run(service.run(host_store=store, attestation=attestation))
|