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,442 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import selectors
5
+ import subprocess
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Final
10
+
11
+ from agentic_runner.integrations.git.workspace import (
12
+ require_runner_owned_git_dir,
13
+ resolve_workspace_path,
14
+ )
15
+ from agentic_runner_contracts.redaction import redact_secret_like_text
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class GitEvidence:
20
+ """Bounded, redacted git status and diff evidence for worker handoff."""
21
+
22
+ workspace_path: Path
23
+ status: str
24
+ diff: str
25
+ stderr: str
26
+ status_returncode: int
27
+ diff_returncode: int
28
+
29
+
30
+ _MAX_UNTRACKED_FILE_BYTES: Final = 65_536
31
+ _PIPE_READ_CHUNK_BYTES: Final = 8192
32
+ _TRUNCATION_MARKER = "\n[TRUNCATED]\n"
33
+
34
+
35
+ def collect_git_evidence(
36
+ *,
37
+ workspace_path: Path,
38
+ workspace_root: Path,
39
+ output_limit_bytes: int,
40
+ command_timeout_seconds: int = 10,
41
+ ) -> GitEvidence:
42
+ """Collect bounded, redacted status and diff from a workspace under its root."""
43
+
44
+ if output_limit_bytes <= 0:
45
+ raise ValueError("output_limit_bytes must be positive")
46
+ if command_timeout_seconds <= 0:
47
+ raise ValueError("command_timeout_seconds must be positive")
48
+
49
+ resolved_workspace = resolve_workspace_path(workspace_root, workspace_path)
50
+ # The other seam that runs the Runner's git over a tree the Contract owns, with the
51
+ # same `safe.directory` suppression — `status`/`diff` honour `core.fsmonitor` and
52
+ # `filter.*.clean` just as `commit` does.
53
+ require_runner_owned_git_dir(resolved_workspace)
54
+ status = _run_git_evidence_command(
55
+ ["status", "--short", "--untracked-files=all", "--porcelain=v1"],
56
+ cwd=resolved_workspace,
57
+ output_limit_bytes=output_limit_bytes,
58
+ command_timeout_seconds=command_timeout_seconds,
59
+ )
60
+ diff = _run_git_evidence_command(
61
+ [
62
+ "diff",
63
+ "--no-ext-diff",
64
+ "--no-textconv",
65
+ "--no-color",
66
+ "--src-prefix=a/",
67
+ "--dst-prefix=b/",
68
+ "--",
69
+ ],
70
+ cwd=resolved_workspace,
71
+ output_limit_bytes=output_limit_bytes,
72
+ command_timeout_seconds=command_timeout_seconds,
73
+ )
74
+ staged_diff = _run_git_evidence_command(
75
+ [
76
+ "diff",
77
+ "--cached",
78
+ "--no-ext-diff",
79
+ "--no-textconv",
80
+ "--no-color",
81
+ "--src-prefix=a/",
82
+ "--dst-prefix=b/",
83
+ "--",
84
+ ],
85
+ cwd=resolved_workspace,
86
+ output_limit_bytes=output_limit_bytes,
87
+ command_timeout_seconds=command_timeout_seconds,
88
+ )
89
+ untracked_files = _run_git_evidence_command(
90
+ ["ls-files", "--others", "--exclude-standard", "-z"],
91
+ cwd=resolved_workspace,
92
+ output_limit_bytes=output_limit_bytes,
93
+ command_timeout_seconds=command_timeout_seconds,
94
+ )
95
+ untracked_diff = _collect_untracked_text_evidence(
96
+ workspace_path=resolved_workspace,
97
+ git_paths=untracked_files.stdout,
98
+ output_limit_bytes=output_limit_bytes,
99
+ )
100
+ combined_diff = "".join((diff.stdout, staged_diff.stdout, untracked_diff))
101
+ return build_git_evidence(
102
+ workspace_path=resolved_workspace,
103
+ status=status.stdout,
104
+ diff=combined_diff,
105
+ stderr="\n".join(
106
+ part
107
+ for part in (status.stderr, diff.stderr, staged_diff.stderr, untracked_files.stderr)
108
+ if part
109
+ ),
110
+ status_returncode=status.returncode,
111
+ diff_returncode=_first_nonzero(
112
+ diff.returncode,
113
+ staged_diff.returncode,
114
+ untracked_files.returncode,
115
+ ),
116
+ output_limit_bytes=output_limit_bytes,
117
+ )
118
+
119
+
120
+ def build_git_evidence(
121
+ *,
122
+ workspace_path: Path,
123
+ status: str,
124
+ diff: str,
125
+ stderr: str = "",
126
+ status_returncode: int = 0,
127
+ diff_returncode: int = 0,
128
+ output_limit_bytes: int,
129
+ ) -> GitEvidence:
130
+ """Build evidence from deterministic text, applying the same redaction and limits."""
131
+
132
+ if output_limit_bytes <= 0:
133
+ raise ValueError("output_limit_bytes must be positive")
134
+ return GitEvidence(
135
+ workspace_path=workspace_path.resolve(),
136
+ **_limit_evidence_fields_to_total_budget(
137
+ status=redact_git_evidence_text(status),
138
+ diff=redact_git_evidence_text(diff),
139
+ stderr=redact_git_evidence_text(stderr),
140
+ output_limit_bytes=output_limit_bytes,
141
+ ),
142
+ status_returncode=status_returncode,
143
+ diff_returncode=diff_returncode,
144
+ )
145
+
146
+
147
+ def redact_git_evidence_text(text: str) -> str:
148
+ """Redact common token, credentialed URL, and private-key forms from evidence text."""
149
+
150
+ return redact_secret_like_text(text)
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class _EvidenceCommandResult:
155
+ stdout: str
156
+ stderr: str
157
+ returncode: int
158
+
159
+
160
+ def _run_git_evidence_command(
161
+ args: list[str],
162
+ *,
163
+ cwd: Path,
164
+ output_limit_bytes: int,
165
+ command_timeout_seconds: int,
166
+ ) -> _EvidenceCommandResult:
167
+ process = subprocess.Popen(
168
+ [
169
+ "git",
170
+ "-c",
171
+ "credential.helper=",
172
+ "-c",
173
+ "core.fsmonitor=false",
174
+ "-c",
175
+ f"core.hooksPath={os.devnull}",
176
+ # The same two the workspace adapter carries, for the same reason: this runs
177
+ # as the Runner over a worktree owned by the Contract's uid (ADR-0015 §1).
178
+ # Without `safe.directory` git refuses it outright with "dubious ownership";
179
+ # without `diff.ignoreSubmodules` a repository the Directive `git init`-ed
180
+ # inside its checkout would be descended into, and its config — which the
181
+ # Contract owns — read by the Runner. See integrations/git/workspace._run_git.
182
+ "-c",
183
+ f"safe.directory={cwd}",
184
+ "-c",
185
+ "diff.ignoreSubmodules=dirty",
186
+ *args,
187
+ ],
188
+ cwd=cwd,
189
+ env=_git_evidence_env(),
190
+ shell=False,
191
+ stdout=subprocess.PIPE,
192
+ stderr=subprocess.PIPE,
193
+ )
194
+ stdout, stderr = _read_process_output_with_limit(
195
+ process,
196
+ output_limit_bytes=output_limit_bytes,
197
+ command_timeout_seconds=command_timeout_seconds,
198
+ )
199
+ return _EvidenceCommandResult(
200
+ stdout=stdout,
201
+ stderr=stderr,
202
+ returncode=process.returncode if process.returncode is not None else -1,
203
+ )
204
+
205
+
206
+ def _read_process_output_with_limit(
207
+ process: subprocess.Popen[bytes],
208
+ *,
209
+ output_limit_bytes: int,
210
+ command_timeout_seconds: int,
211
+ ) -> tuple[str, str]:
212
+ stdout_buffer = bytearray()
213
+ stderr_buffer = bytearray()
214
+ stdout_truncated = False
215
+ stderr_truncated = False
216
+ deadline = time.monotonic() + command_timeout_seconds
217
+ selector = selectors.DefaultSelector()
218
+ if process.stdout is not None:
219
+ selector.register(process.stdout, selectors.EVENT_READ, "stdout")
220
+ if process.stderr is not None:
221
+ selector.register(process.stderr, selectors.EVENT_READ, "stderr")
222
+
223
+ try:
224
+ while selector.get_map():
225
+ remaining_seconds = deadline - time.monotonic()
226
+ if remaining_seconds <= 0:
227
+ process.kill()
228
+ break
229
+ for key, _events in selector.select(timeout=remaining_seconds):
230
+ stream = key.fileobj
231
+ data = os.read(key.fd, _PIPE_READ_CHUNK_BYTES)
232
+ if not data:
233
+ selector.unregister(stream)
234
+ continue
235
+ if key.data == "stdout":
236
+ stdout_truncated = (
237
+ _append_bytes_with_limit(
238
+ stdout_buffer,
239
+ data,
240
+ output_limit_bytes,
241
+ )
242
+ or stdout_truncated
243
+ )
244
+ else:
245
+ stderr_truncated = (
246
+ _append_bytes_with_limit(
247
+ stderr_buffer,
248
+ data,
249
+ output_limit_bytes,
250
+ )
251
+ or stderr_truncated
252
+ )
253
+ finally:
254
+ selector.close()
255
+
256
+ try:
257
+ process.wait(timeout=0)
258
+ except subprocess.TimeoutExpired:
259
+ process.kill()
260
+ process.wait()
261
+
262
+ stdout = stdout_buffer.decode(errors="replace")
263
+ stderr = stderr_buffer.decode(errors="replace")
264
+ if stdout_truncated:
265
+ stdout = _append_truncation_marker(stdout, output_limit_bytes)
266
+ if stderr_truncated:
267
+ stderr = _append_truncation_marker(stderr, output_limit_bytes)
268
+ return stdout, stderr
269
+
270
+
271
+ def _append_bytes_with_limit(buffer: bytearray, data: bytes, limit_bytes: int) -> bool:
272
+ remaining_bytes = limit_bytes - len(buffer)
273
+ if remaining_bytes <= 0:
274
+ return True
275
+ buffer.extend(data[:remaining_bytes])
276
+ return len(data) > remaining_bytes
277
+
278
+
279
+ def _collect_untracked_text_evidence(
280
+ *,
281
+ workspace_path: Path,
282
+ git_paths: str,
283
+ output_limit_bytes: int,
284
+ ) -> str:
285
+ evidence = ""
286
+ for git_path in (path for path in git_paths.split("\0") if path):
287
+ section = _build_untracked_file_section(
288
+ workspace_path=workspace_path,
289
+ git_path=git_path,
290
+ output_limit_bytes=output_limit_bytes,
291
+ )
292
+ if section:
293
+ evidence = _append_text_with_limit(evidence, section, output_limit_bytes)
294
+ if len(evidence.encode()) >= output_limit_bytes:
295
+ break
296
+ return evidence
297
+
298
+
299
+ def _build_untracked_file_section(
300
+ *,
301
+ workspace_path: Path,
302
+ git_path: str,
303
+ output_limit_bytes: int,
304
+ ) -> str:
305
+ relative_path = Path(git_path)
306
+ rendered_git_path = _render_synthetic_git_path(git_path)
307
+ if relative_path.is_absolute() or ".." in relative_path.parts:
308
+ return _synthetic_untracked_section(git_path, "omitted unsafe untracked path")
309
+
310
+ candidate = workspace_path / relative_path
311
+ if candidate.is_symlink():
312
+ return _synthetic_untracked_section(git_path, "omitted untracked symlink")
313
+
314
+ resolved_candidate = candidate.resolve()
315
+ if workspace_path not in resolved_candidate.parents and resolved_candidate != workspace_path:
316
+ return _synthetic_untracked_section(git_path, "omitted untracked path outside workspace")
317
+ if not resolved_candidate.is_file():
318
+ return _synthetic_untracked_section(git_path, "omitted non-file untracked path")
319
+
320
+ content = _read_untracked_text_file(
321
+ resolved_candidate,
322
+ max_bytes=min(output_limit_bytes, _MAX_UNTRACKED_FILE_BYTES),
323
+ )
324
+ if content is None:
325
+ return _synthetic_untracked_section(git_path, "omitted binary or non-UTF-8 untracked file")
326
+
327
+ lines = content.splitlines(keepends=True)
328
+ prefixed_content = "".join(f"+{line}" for line in lines)
329
+ if content and not content.endswith("\n"):
330
+ prefixed_content += "\n\\n"
331
+ return (
332
+ f"diff --git a/{rendered_git_path} b/{rendered_git_path}\n"
333
+ "new file mode 100644\n"
334
+ "--- /dev/null\n"
335
+ f"+++ b/{rendered_git_path}\n"
336
+ f"{prefixed_content}"
337
+ )
338
+
339
+
340
+ def _read_untracked_text_file(path: Path, *, max_bytes: int) -> str | None:
341
+ with path.open("rb") as file:
342
+ content = file.read(max_bytes + 1)
343
+ bounded_content = content[:max_bytes]
344
+ if b"\0" in bounded_content:
345
+ return None
346
+ try:
347
+ text = bounded_content.decode("utf-8")
348
+ except UnicodeDecodeError:
349
+ return None
350
+ if len(content) > max_bytes:
351
+ text += "\n[TRUNCATED]\n"
352
+ return text
353
+
354
+
355
+ def _synthetic_untracked_section(git_path: str, message: str) -> str:
356
+ rendered_git_path = _render_synthetic_git_path(git_path)
357
+ return (
358
+ f"diff --git a/{rendered_git_path} b/{rendered_git_path}\n"
359
+ "new file evidence omitted\n"
360
+ f"# {message}\n"
361
+ )
362
+
363
+
364
+ def _render_synthetic_git_path(git_path: str) -> str:
365
+ rendered_characters: list[str] = []
366
+ for character in git_path:
367
+ codepoint = ord(character)
368
+ if character == "\\":
369
+ rendered_characters.append("\\\\")
370
+ elif character == "\n":
371
+ rendered_characters.append("\\n")
372
+ elif character == "\r":
373
+ rendered_characters.append("\\r")
374
+ elif character == "\t":
375
+ rendered_characters.append("\\t")
376
+ elif codepoint < 32 or codepoint == 127:
377
+ rendered_characters.append(f"\\x{codepoint:02x}")
378
+ else:
379
+ rendered_characters.append(character)
380
+ return "".join(rendered_characters)
381
+
382
+
383
+ def _first_nonzero(*returncodes: int) -> int:
384
+ for returncode in returncodes:
385
+ if returncode != 0:
386
+ return returncode
387
+ return 0
388
+
389
+
390
+ def _git_evidence_env() -> dict[str, str]:
391
+ env: dict[str, str] = {}
392
+ for key in ("PATH", "LANG", "LC_ALL", "LC_CTYPE"):
393
+ value = os.environ.get(key)
394
+ if value is not None:
395
+ env[key] = value
396
+ env["GIT_TERMINAL_PROMPT"] = "0"
397
+ env["GIT_CONFIG_GLOBAL"] = os.devnull
398
+ env["GIT_CONFIG_NOSYSTEM"] = "1"
399
+ return env
400
+
401
+
402
+ def _limit_evidence_fields_to_total_budget(
403
+ *,
404
+ status: str,
405
+ diff: str,
406
+ stderr: str,
407
+ output_limit_bytes: int,
408
+ ) -> dict[str, str]:
409
+ remaining_bytes = output_limit_bytes
410
+ limited_status = _limit_text_bytes(status, remaining_bytes)
411
+ remaining_bytes -= len(limited_status.encode())
412
+ limited_diff = _limit_text_bytes(diff, remaining_bytes)
413
+ remaining_bytes -= len(limited_diff.encode())
414
+ limited_stderr = _limit_text_bytes(stderr, remaining_bytes)
415
+ return {
416
+ "status": limited_status,
417
+ "diff": limited_diff,
418
+ "stderr": limited_stderr,
419
+ }
420
+
421
+
422
+ def _append_text_with_limit(current: str, addition: str, limit_bytes: int) -> str:
423
+ remaining_bytes = limit_bytes - len(current.encode())
424
+ if remaining_bytes <= 0:
425
+ return current
426
+ return current + _limit_text_bytes(addition, remaining_bytes)
427
+
428
+
429
+ def _limit_text_bytes(text: str, limit_bytes: int) -> str:
430
+ if limit_bytes <= 0:
431
+ return ""
432
+ encoded = text.encode()
433
+ if len(encoded) <= limit_bytes:
434
+ return text
435
+ if limit_bytes <= len(_TRUNCATION_MARKER.encode()):
436
+ return encoded[:limit_bytes].decode(errors="ignore")
437
+ content_limit = limit_bytes - len(_TRUNCATION_MARKER.encode())
438
+ return encoded[:content_limit].decode(errors="ignore") + _TRUNCATION_MARKER
439
+
440
+
441
+ def _append_truncation_marker(text: str, limit_bytes: int) -> str:
442
+ return _limit_text_bytes(text + _TRUNCATION_MARKER, limit_bytes)