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.
Files changed (54) hide show
  1. agentic_runner/__init__.py +12 -0
  2. agentic_runner/activities.py +4918 -0
  3. agentic_runner/callback.py +342 -0
  4. agentic_runner/child_watcher.py +66 -0
  5. agentic_runner/cli.py +416 -0
  6. agentic_runner/config.py +105 -0
  7. agentic_runner/credentials.py +252 -0
  8. agentic_runner/device_login_activities.py +79 -0
  9. agentic_runner/egress.py +243 -0
  10. agentic_runner/heartbeat_link.py +249 -0
  11. agentic_runner/hooks.py +455 -0
  12. agentic_runner/host_store.py +295 -0
  13. agentic_runner/integrations/__init__.py +0 -0
  14. agentic_runner/integrations/git/__init__.py +1 -0
  15. agentic_runner/integrations/git/contracts.py +198 -0
  16. agentic_runner/integrations/git/evidence.py +442 -0
  17. agentic_runner/integrations/git/fake_workspace.py +339 -0
  18. agentic_runner/integrations/git/workspace.py +921 -0
  19. agentic_runner/integrations/github/__init__.py +53 -0
  20. agentic_runner/integrations/github/auth.py +171 -0
  21. agentic_runner/integrations/github/fake_client.py +494 -0
  22. agentic_runner/integrations/github/gh_client.py +944 -0
  23. agentic_runner/lifecycle.py +48 -0
  24. agentic_runner/llm_proxy.py +937 -0
  25. agentic_runner/mcp.py +342 -0
  26. agentic_runner/message_store.py +341 -0
  27. agentic_runner/py.typed +0 -0
  28. agentic_runner/recipient_key_secret.py +134 -0
  29. agentic_runner/registration.py +363 -0
  30. agentic_runner/runtime/__init__.py +0 -0
  31. agentic_runner/runtime/verifier_command.py +344 -0
  32. agentic_runner/sealed_box.py +509 -0
  33. agentic_runner/service.py +1068 -0
  34. agentic_runner/tiny_http.py +133 -0
  35. agentic_runner/triage_activities.py +113 -0
  36. agentic_runner/user_sources.py +546 -0
  37. agentic_runner/workers/__init__.py +1 -0
  38. agentic_runner/workers/_runtime_support.py +388 -0
  39. agentic_runner/workers/agent_runtime.py +93 -0
  40. agentic_runner/workers/claude_runtime.py +226 -0
  41. agentic_runner/workers/codex_runtime.py +311 -0
  42. agentic_runner/workers/command_policy.py +250 -0
  43. agentic_runner/workers/contract_device_login.py +211 -0
  44. agentic_runner/workers/contract_isolation.py +500 -0
  45. agentic_runner/workers/fastapi_client.py +396 -0
  46. agentic_runner/workers/harness_usage.py +65 -0
  47. agentic_runner/workers/mcp_config.py +111 -0
  48. agentic_runner/workers/settings.py +314 -0
  49. agentic_runner/workstation.py +687 -0
  50. agentic_runner-2.6.0.dist-info/METADATA +49 -0
  51. agentic_runner-2.6.0.dist-info/RECORD +54 -0
  52. agentic_runner-2.6.0.dist-info/WHEEL +4 -0
  53. agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
  54. 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))