agentic-runner 2.8.0__tar.gz → 3.1.0__tar.gz

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 (64) hide show
  1. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/PKG-INFO +1 -1
  2. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/activities.py +187 -14
  4. agentic_runner-3.1.0/src/agentic_runner/build.py +33 -0
  5. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/host_store.py +6 -10
  6. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/git/evidence.py +27 -0
  7. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/lifecycle.py +4 -5
  8. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/message_store.py +7 -7
  9. agentic_runner-3.1.0/src/agentic_runner/private_state.py +138 -0
  10. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/registration.py +6 -10
  11. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/sealed_box.py +6 -8
  12. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/service.py +33 -6
  13. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/testing/control_plane.py +4 -0
  14. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/_runtime_support.py +31 -2
  15. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/agent_runtime.py +23 -0
  16. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/claude_runtime.py +26 -3
  17. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/codex_runtime.py +40 -3
  18. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/contract_isolation.py +13 -12
  19. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workstation.py +1 -18
  20. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/.gitignore +0 -0
  21. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/LICENSE +0 -0
  22. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/README.md +0 -0
  23. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/pyproject.toml +0 -0
  24. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/attempts.py +0 -0
  25. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/callback.py +0 -0
  26. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/child_watcher.py +0 -0
  27. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/cli.py +0 -0
  28. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/config.py +0 -0
  29. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/credentials.py +0 -0
  30. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/device_login_activities.py +0 -0
  31. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/egress.py +0 -0
  32. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/heartbeat_link.py +0 -0
  33. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/hooks.py +0 -0
  34. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/__init__.py +0 -0
  35. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  36. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  37. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  38. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  39. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  40. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  41. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  42. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  43. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/llm_proxy.py +0 -0
  44. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/mcp.py +0 -0
  45. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/py.typed +0 -0
  46. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  47. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/runtime/__init__.py +0 -0
  48. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  49. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/testing/__init__.py +0 -0
  50. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/testing/__main__.py +0 -0
  51. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/testing/plugin.py +0 -0
  52. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  53. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/testing/workflow.py +0 -0
  54. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/tiny_http.py +0 -0
  55. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/triage_activities.py +0 -0
  56. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/user_sources.py +0 -0
  57. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/__init__.py +0 -0
  58. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/command_policy.py +0 -0
  59. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  60. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  61. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  62. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  63. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/settings.py +0 -0
  64. {agentic_runner-2.8.0 → agentic_runner-3.1.0}/src/agentic_runner/workers/skills.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 2.8.0
3
+ Version: 3.1.0
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "2.8.0"
10
+ __version__ = "3.1.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -38,7 +38,12 @@ from uuid import UUID
38
38
  from temporalio import activity
39
39
  from temporalio.exceptions import ApplicationError
40
40
 
41
- from agentic_runner.attempts import AttemptRecords, PriorAttemptAliveError, fence_work_record
41
+ from agentic_runner.attempts import (
42
+ AttemptRecords,
43
+ PriorAttemptAliveError,
44
+ fence_work_record,
45
+ refuse_if_prior_attempt_alive,
46
+ )
42
47
  from agentic_runner.callback import (
43
48
  CALLBACK_TOKEN_ENV,
44
49
  AnnotateRequest,
@@ -86,6 +91,7 @@ from agentic_runner.integrations.git.contracts import (
86
91
  PrepareVerifierWorkspaceRequest,
87
92
  PushBranchRequest,
88
93
  )
94
+ from agentic_runner.integrations.git.evidence import read_head_commit
89
95
  from agentic_runner.integrations.git.workspace import (
90
96
  GitWorkspacePolicyError,
91
97
  contract_workspace_path,
@@ -115,6 +121,7 @@ from agentic_runner.workers.agent_runtime import (
115
121
  AuthModel,
116
122
  DirectiveRequest,
117
123
  DirectiveResult,
124
+ ResumableAgentRuntime,
118
125
  )
119
126
  from agentic_runner.workers.contract_isolation import (
120
127
  NO_CONTRACT,
@@ -332,8 +339,87 @@ _APPROVAL_POLLS_PER_ACTIVITY = 10
332
339
  _APPROVAL_ACTIVITY_TIME_BUDGET_SECONDS = 360.0
333
340
 
334
341
 
342
+ @dataclass(frozen=True, slots=True)
343
+ class HarnessSession:
344
+ """The harness session a Directive attempt started, and where it ran (local-agents 16).
345
+
346
+ A retry may resume it only when the Contract, the Workspace path and the head all
347
+ match exactly (ADR-0007, amendment 2026-10-03).
348
+ """
349
+
350
+ session_id: str
351
+ contract_id: str | None
352
+ workspace_path: str
353
+ head: str | None
354
+
355
+ @classmethod
356
+ def from_heartbeat(cls, details: object) -> "HarnessSession | None":
357
+ if not isinstance(details, Mapping):
358
+ return None
359
+ session_id = details.get("session_id")
360
+ contract_id = details.get("contract_id")
361
+ workspace_path = details.get("workspace_path")
362
+ head = details.get("head")
363
+ if (
364
+ not isinstance(session_id, str)
365
+ or not isinstance(workspace_path, str)
366
+ or not isinstance(contract_id, str | None)
367
+ or not isinstance(head, str | None)
368
+ ):
369
+ return None
370
+ return cls(session_id, contract_id, workspace_path, head)
371
+
372
+
373
+ def _why_not_resume(
374
+ prior: HarnessSession | None,
375
+ here: HarnessSession,
376
+ agent_runtime: AgentRuntime,
377
+ sandbox: DirectiveSandbox | None,
378
+ ) -> str | None:
379
+ if prior is None:
380
+ return "no_prior_session"
381
+ if prior.contract_id != here.contract_id:
382
+ return "contract_changed"
383
+ if prior.workspace_path != here.workspace_path:
384
+ return "workspace_changed"
385
+ if here.head is None or prior.head != here.head:
386
+ return "head_changed"
387
+ if not isinstance(agent_runtime, ResumableAgentRuntime):
388
+ return "runtime_cannot_resume"
389
+ if not agent_runtime.has_session(prior.session_id, sandbox):
390
+ return "session_not_found"
391
+ return None
392
+
393
+
394
+ class _Liveness:
395
+ """What one attempt's heartbeats carry: its first start, and its harness session.
396
+
397
+ Both ride in the Temporal heartbeat details rather than in the Runner's process
398
+ (ADR-0013 §3), so the retry that follows a lost pod still finds them.
399
+ """
400
+
401
+ def __init__(
402
+ self, *, first_started: float, now: float, prior_session: HarnessSession | None
403
+ ) -> None:
404
+ self.earlier_attempts_seconds = max(0.0, now - first_started)
405
+ self.prior_session = prior_session
406
+ self._first_started = first_started
407
+ self._session: HarnessSession | None = None
408
+
409
+ def details(self) -> tuple[object, ...]:
410
+ if self._session is None:
411
+ return (self._first_started,)
412
+ return (self._first_started, asdict(self._session))
413
+
414
+ def record_session(self, session: HarnessSession) -> None:
415
+ self._session = session
416
+ # At once, not at the next beat: the attempt may be lost before then.
417
+ if activity.in_activity():
418
+ activity.heartbeat(*self.details())
419
+
420
+
335
421
  @contextlib.asynccontextmanager
336
- async def _liveness_heartbeats() -> AsyncIterator[float]:
422
+ async def _liveness_heartbeats() -> AsyncIterator[_Liveness]:
337
423
  """Emit periodic activity heartbeats from a background task while the body runs.
338
424
 
339
425
  Async-activity heartbeats are delivered on the worker event loop, so the wrapped
@@ -345,24 +431,29 @@ async def _liveness_heartbeats() -> AsyncIterator[float]:
345
431
  47, 23 item 4). Each beat carries the first attempt's wall-clock start as its
346
432
  heartbeat details, so the retry that follows a laptop's sleep -- the attempt Temporal
347
433
  failed on its heartbeat timeout -- charges the Work Record's wall-clock from there:
348
- sleep is an outage, and the Budget keeps running through it.
434
+ sleep is an outage, and the Budget keeps running through it. Once the Agent Runtime
435
+ names its session, the beats carry that too (local-agents 16).
349
436
  """
437
+ now = time.time()
350
438
  if not activity.in_activity():
351
- yield 0.0
439
+ yield _Liveness(first_started=now, now=now, prior_session=None)
352
440
  return
353
441
 
354
- now = time.time()
355
442
  details = activity.info().heartbeat_details
356
- first_started = float(details[0]) if details else now
443
+ liveness = _Liveness(
444
+ first_started=float(details[0]) if details else now,
445
+ now=now,
446
+ prior_session=HarnessSession.from_heartbeat(details[1]) if len(details) > 1 else None,
447
+ )
357
448
 
358
449
  async def beat() -> None:
359
450
  while True:
360
- activity.heartbeat(first_started)
451
+ activity.heartbeat(*liveness.details())
361
452
  await asyncio.sleep(_HEARTBEAT_INTERVAL_SECONDS)
362
453
 
363
454
  heartbeater = asyncio.create_task(beat())
364
455
  try:
365
- yield max(0.0, now - first_started)
456
+ yield liveness
366
457
  finally:
367
458
  heartbeater.cancel()
368
459
  with contextlib.suppress(asyncio.CancelledError):
@@ -1337,6 +1428,7 @@ def wipe_contract_residue(
1337
1428
  # Where a Runner-side routing refusal lands in the trail (PRD issue 42, 17 A11).
1338
1429
  _ROUTING_EVIDENCE_SOURCE = "runner.routing"
1339
1430
  PRIOR_ATTEMPT_ALIVE_EVIDENCE_SOURCE = "runner.prior_attempt_alive"
1431
+ HARNESS_SESSION_EVIDENCE_SOURCE = "runner.harness_session"
1340
1432
  DIRECTIVE_TOKEN_EVIDENCE_SOURCE = "runner.directive_token"
1341
1433
  AGENT_RUNTIME_EVIDENCE_SOURCE = "runner.agent_runtime"
1342
1434
  SKILLS_EVIDENCE_SOURCE = "runner.skills"
@@ -1613,6 +1705,63 @@ class RunnerRalphActivities:
1613
1705
  },
1614
1706
  )
1615
1707
 
1708
+ async def _harness_session(
1709
+ self,
1710
+ liveness: _Liveness,
1711
+ *,
1712
+ work_record_id: str,
1713
+ directive_number: int,
1714
+ agent_runtime: AgentRuntime,
1715
+ contract_id: str | None,
1716
+ workspace_path: Path,
1717
+ sandbox: DirectiveSandbox | None,
1718
+ ) -> tuple[str | None, Callable[[str], None]]:
1719
+ """Resume the session an earlier attempt of this Directive started, or start fresh.
1720
+
1721
+ Returns the session to resume, if any, and the hook that files a fresh one in the
1722
+ heartbeat details. Every retry writes one Evidence event saying which, and why --
1723
+ never what the session holds (local-agents 16, ADR-0007 amendment 2026-10-03).
1724
+ """
1725
+
1726
+ head = read_head_commit(
1727
+ workspace_path=workspace_path, workspace_root=self._require_workspace_root()
1728
+ )
1729
+ here = HarnessSession(
1730
+ session_id="",
1731
+ contract_id=contract_id,
1732
+ workspace_path=str(workspace_path),
1733
+ head=head,
1734
+ )
1735
+
1736
+ def start_fresh(session_id: str) -> None:
1737
+ liveness.record_session(replace(here, session_id=session_id))
1738
+
1739
+ attempt = activity.info().attempt if activity.in_activity() else 1
1740
+ if attempt == 1:
1741
+ return None, start_fresh
1742
+ # Made explicit rather than left to the spawn: the session to resume must not be
1743
+ # one a still-running harness is writing (RR-05). A refusal raises into `_fenced`.
1744
+ refuse_if_prior_attempt_alive()
1745
+ prior = liveness.prior_session
1746
+ reason = _why_not_resume(prior, here, agent_runtime, sandbox)
1747
+ await self._fastapi_client.append_evidence(
1748
+ work_record_id,
1749
+ source=HARNESS_SESSION_EVIDENCE_SOURCE,
1750
+ actor=self._actor(),
1751
+ payload={
1752
+ "event": "directive.harness_session",
1753
+ "work_record_id": work_record_id,
1754
+ "directive_number": directive_number,
1755
+ "attempt": attempt,
1756
+ "decision": "fresh" if reason else "resumed",
1757
+ "reason": reason or "contract_workspace_and_head_match",
1758
+ },
1759
+ )
1760
+ if reason is not None or prior is None:
1761
+ return None, start_fresh
1762
+ liveness.record_session(prior)
1763
+ return prior.session_id, start_fresh
1764
+
1616
1765
  @activity.defn(name="create_or_update_branch_pr")
1617
1766
  async def create_or_update_branch_pr(
1618
1767
  self,
@@ -1652,7 +1801,7 @@ class RunnerRalphActivities:
1652
1801
  asked: list[QuestionAsked],
1653
1802
  ) -> BranchPullRequestOutput:
1654
1803
  async with (
1655
- _liveness_heartbeats() as earlier_attempts_seconds,
1804
+ _liveness_heartbeats() as liveness,
1656
1805
  contextlib.AsyncExitStack() as attempt_stack,
1657
1806
  ):
1658
1807
  if (
@@ -1670,7 +1819,7 @@ class RunnerRalphActivities:
1670
1819
  pr_created=False,
1671
1820
  )
1672
1821
 
1673
- directive_started = self._monotonic() - earlier_attempts_seconds
1822
+ directive_started = self._monotonic() - liveness.earlier_attempts_seconds
1674
1823
  await self._advance_work_record_lifecycle(
1675
1824
  request.work_record_id,
1676
1825
  to_state="IN_PROGRESS",
@@ -1798,10 +1947,22 @@ class RunnerRalphActivities:
1798
1947
  )
1799
1948
  prompt_notes: list[str] = []
1800
1949
  await attempt.hooks.phase(HookName.PRE_RUNTIME)
1950
+ sandbox = self._directive_sandbox(runtime_state, checkout_workspace)
1951
+ resume_session_id, on_session_started = await self._harness_session(
1952
+ liveness,
1953
+ work_record_id=request.work_record_id,
1954
+ directive_number=request.directive_number,
1955
+ agent_runtime=agent_runtime,
1956
+ contract_id=contract_id,
1957
+ workspace_path=checkout_workspace,
1958
+ sandbox=sandbox,
1959
+ )
1801
1960
  codex_result = await agent_runtime.execute_directive(
1802
1961
  DirectiveRequest(
1803
- sandbox=self._directive_sandbox(runtime_state, checkout_workspace),
1962
+ sandbox=sandbox,
1804
1963
  workspace_path=checkout_workspace,
1964
+ resume_session_id=resume_session_id,
1965
+ on_session_started=on_session_started,
1805
1966
  # The attempt's callback socket and bearer, plus whatever the
1806
1967
  # `environment` hook exported. The env allow-list gains these names
1807
1968
  # and nothing else (PRD issue 09's invariant, issue 45's amendment).
@@ -4500,7 +4661,7 @@ class RunnerRalphActivities:
4500
4661
  """
4501
4662
 
4502
4663
  async with (
4503
- _liveness_heartbeats() as earlier_attempts_seconds,
4664
+ _liveness_heartbeats() as liveness,
4504
4665
  contextlib.AsyncExitStack() as attempt_stack,
4505
4666
  ):
4506
4667
  if (
@@ -4516,7 +4677,7 @@ class RunnerRalphActivities:
4516
4677
  branch_head_sha="",
4517
4678
  summary="directive unavailable: runtime dependencies missing",
4518
4679
  )
4519
- directive_started = self._monotonic() - earlier_attempts_seconds
4680
+ directive_started = self._monotonic() - liveness.earlier_attempts_seconds
4520
4681
  # Loop back into the working state for another runtime turn.
4521
4682
  await self._advance_work_record_lifecycle(
4522
4683
  request.work_record_id,
@@ -4584,10 +4745,22 @@ class RunnerRalphActivities:
4584
4745
  )
4585
4746
  prompt_notes: list[str] = []
4586
4747
  await attempt.hooks.phase(HookName.PRE_RUNTIME)
4748
+ sandbox = self._directive_sandbox(state, state.workspace_path)
4749
+ resume_session_id, on_session_started = await self._harness_session(
4750
+ liveness,
4751
+ work_record_id=request.work_record_id,
4752
+ directive_number=request.directive_number,
4753
+ agent_runtime=agent_runtime,
4754
+ contract_id=state.contract_id,
4755
+ workspace_path=state.workspace_path,
4756
+ sandbox=sandbox,
4757
+ )
4587
4758
  codex_result = await agent_runtime.execute_directive(
4588
4759
  DirectiveRequest(
4589
- sandbox=self._directive_sandbox(state, state.workspace_path),
4760
+ sandbox=sandbox,
4590
4761
  workspace_path=state.workspace_path,
4762
+ resume_session_id=resume_session_id,
4763
+ on_session_started=on_session_started,
4591
4764
  extra_env=attempt.env,
4592
4765
  mcp_servers=attempt.mcp_servers,
4593
4766
  egress_allow_list=attempt.egress_allow_list,
@@ -0,0 +1,33 @@
1
+ """This build's own digest: the ``build_id`` a Runner reports and a release publishes.
2
+
3
+ Its own module, with no Runner import, because registration (the bootstrap request) and
4
+ the workstation (the heartbeat attestation) both need it and the workstation imports
5
+ registration. The release pipeline calls it on the installed wheel and on the image, so
6
+ any change to how it is computed changes every published ``build-id.txt`` with it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from hashlib import sha256
12
+ from pathlib import Path
13
+
14
+ import agentic_runner
15
+
16
+ __all__ = ["build_id"]
17
+
18
+ _PACKAGE = Path(agentic_runner.__file__).parent
19
+
20
+
21
+ def build_id(package: Path = _PACKAGE) -> str:
22
+ """The SHA-256 of the package's ``.py`` sources, path and bytes, in sorted order.
23
+
24
+ Self-reported build identity (12 B7), not a signature: the consoles say
25
+ "self-reported" beside it. Only ``.py`` files count, so a wheel, the image and a
26
+ Homebrew install of one release give the same value whatever bytecode each compiled.
27
+ """
28
+
29
+ digest = sha256()
30
+ for source in sorted(package.rglob("*.py")):
31
+ digest.update(source.relative_to(package).as_posix().encode())
32
+ digest.update(source.read_bytes())
33
+ return digest.hexdigest()
@@ -19,7 +19,6 @@ operator's act, through ``agentic-runner credential set``.
19
19
 
20
20
  from __future__ import annotations
21
21
 
22
- import os
23
22
  import shutil
24
23
  import subprocess
25
24
  import sys
@@ -28,6 +27,7 @@ from pathlib import Path
28
27
  from typing import Protocol
29
28
 
30
29
  from agentic_runner.credentials import DirectoryCredentialStore
30
+ from agentic_runner.private_state import private_read, private_write
31
31
  from agentic_runner_contracts.runner_registration import StoreKind
32
32
 
33
33
  __all__ = [
@@ -66,16 +66,12 @@ class FileCredentialStore(DirectoryCredentialStore):
66
66
  super().__init__(root)
67
67
  self.root = root
68
68
 
69
+ def get(self, reference: str) -> str | None:
70
+ raw = private_read(self.root / _single_segment(reference))
71
+ return None if raw is None else raw.decode("utf-8").strip()
72
+
69
73
  def put(self, reference: str, value: str) -> None:
70
- self.root.mkdir(parents=True, exist_ok=True)
71
- self.root.chmod(0o700)
72
- path = self.root / _single_segment(reference)
73
- # Created 0600 rather than chmod-ed after: there is no instant at which the value
74
- # sits in a file another uid can open.
75
- descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
76
- with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
77
- handle.write(value)
78
- path.chmod(0o600)
74
+ private_write(self.root / _single_segment(reference), value.encode("utf-8"))
79
75
 
80
76
 
81
77
  class KeychainStore:
@@ -117,6 +117,33 @@ def collect_git_evidence(
117
117
  )
118
118
 
119
119
 
120
+ def read_head_commit(
121
+ *,
122
+ workspace_path: Path,
123
+ workspace_root: Path,
124
+ command_timeout_seconds: int = 10,
125
+ ) -> str | None:
126
+ """The Workspace's HEAD commit, or None when it has none (no clone, an unborn branch).
127
+
128
+ Reads the tree directly rather than through the workspace adapter: an in-place
129
+ Directive retried on a restarted Runner finds its clone on disk but not in the
130
+ adapter's registry.
131
+ """
132
+
133
+ resolved_workspace = resolve_workspace_path(workspace_root, workspace_path)
134
+ if not os.path.lexists(resolved_workspace / ".git"):
135
+ return None
136
+ require_runner_owned_git_dir(resolved_workspace)
137
+ head = _run_git_evidence_command(
138
+ ["rev-parse", "--verify", "--quiet", "HEAD"],
139
+ cwd=resolved_workspace,
140
+ output_limit_bytes=256,
141
+ command_timeout_seconds=command_timeout_seconds,
142
+ )
143
+ commit = head.stdout.strip()
144
+ return commit if head.returncode == 0 and commit else None
145
+
146
+
120
147
  def build_git_evidence(
121
148
  *,
122
149
  workspace_path: Path,
@@ -13,6 +13,7 @@ from pathlib import Path
13
13
 
14
14
  from pydantic import TypeAdapter
15
15
 
16
+ from agentic_runner.private_state import private_read, private_write
16
17
  from agentic_runner_contracts.runner_registration import LifecycleEvent, LifecycleKind
17
18
 
18
19
  __all__ = ["LIFECYCLE_FILENAME", "LifecycleOutbox"]
@@ -33,9 +34,8 @@ class LifecycleOutbox:
33
34
  self._write(events[-_KEEP:])
34
35
 
35
36
  def pending(self) -> list[LifecycleEvent]:
36
- if not self._path.is_file():
37
- return []
38
- return _EVENTS.validate_json(self._path.read_bytes())
37
+ raw = private_read(self._path)
38
+ return [] if raw is None else _EVENTS.validate_json(raw)
39
39
 
40
40
  def acknowledge(self, sent: list[LifecycleEvent]) -> None:
41
41
  """Drop what an acknowledged heartbeat carried; keep anything added meanwhile."""
@@ -44,5 +44,4 @@ class LifecycleOutbox:
44
44
  self._write(remaining)
45
45
 
46
46
  def _write(self, events: list[LifecycleEvent]) -> None:
47
- self._path.parent.mkdir(parents=True, exist_ok=True)
48
- self._path.write_bytes(_EVENTS.dump_json(events))
47
+ private_write(self._path, _EVENTS.dump_json(events))
@@ -32,6 +32,7 @@ from uuid import UUID, uuid4
32
32
 
33
33
  from temporalio.client import Client
34
34
 
35
+ from agentic_runner.private_state import private_write
35
36
  from agentic_runner_contracts.channel_messages import (
36
37
  PENDING_MESSAGES_SIGNAL,
37
38
  MessageEnvelope,
@@ -134,9 +135,11 @@ class MessageStore:
134
135
  )
135
136
  path = self._path(contract_id, work_record_id)
136
137
  _ensure_dir(path.parent)
137
- with path.open("a", encoding="utf-8") as handle:
138
+ descriptor = os.open(
139
+ path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, _FILE_MODE
140
+ )
141
+ with os.fdopen(descriptor, "a", encoding="utf-8") as handle:
138
142
  handle.write(envelope.model_dump_json() + "\n")
139
- path.chmod(_FILE_MODE)
140
143
  return envelope
141
144
 
142
145
  def read(
@@ -187,9 +190,7 @@ class MessageStore:
187
190
  cursors = self._cursors(contract_id, work_record_id)
188
191
  cursors[agent_id] = str(through.message_id)
189
192
  path = self._cursor_path(contract_id, work_record_id)
190
- _ensure_dir(path.parent)
191
- path.write_text(json.dumps(cursors), encoding="utf-8")
192
- path.chmod(_FILE_MODE)
193
+ private_write(path, json.dumps(cursors).encode("utf-8"))
193
194
 
194
195
  def transcript(self, request: TranscriptRequest) -> TranscriptDelivery:
195
196
  """The pull-through's answer: every body, or the Contract state that seals them."""
@@ -222,8 +223,7 @@ class MessageStore:
222
223
  if not stores:
223
224
  return 0
224
225
  marker = contract_dir / SEALED_MARKER
225
- marker.write_text(json.dumps({"contract_state": contract_state}), encoding="utf-8")
226
- marker.chmod(_FILE_MODE)
226
+ private_write(marker, json.dumps({"contract_state": contract_state}).encode("utf-8"))
227
227
  return len(stores)
228
228
 
229
229
  def sealed_state(self, contract_id: str) -> str | None:
@@ -0,0 +1,138 @@
1
+ """The state directory's one writer and one reader (runner-repo issue 08).
2
+
3
+ The state directory holds the Runner's private identity key and the Recipient Key, so
4
+ nothing in it may be readable by another uid for even an instant, and no crash may leave a
5
+ half-written file where the identity was. Every file the Runner writes there goes through
6
+ :func:`private_write` and every file it reads through :func:`private_read`; both refuse a
7
+ directory or file that is a symlink, belongs to another uid or carries a group or other
8
+ permission bit, rather than tightening it -- a state directory in that shape was put there
9
+ by someone else, and the Runner cannot know what they read while it was open.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import errno
16
+ import os
17
+ import secrets
18
+ import stat
19
+ from collections.abc import Callable, Iterable
20
+ from pathlib import Path
21
+
22
+ __all__ = [
23
+ "UnsafeStateError",
24
+ "ensure_private_dir",
25
+ "private_read",
26
+ "private_write",
27
+ "require_private_files",
28
+ ]
29
+
30
+ _DIR_MODE = 0o700
31
+ _FILE_MODE = 0o600
32
+ _GROUP_OTHER_BITS = 0o077
33
+
34
+
35
+ class UnsafeStateError(RuntimeError):
36
+ """The state directory, or a file in it, is not this Runner's alone."""
37
+
38
+ reason = "unsafe_state_dir"
39
+
40
+
41
+ def ensure_private_dir(path: Path) -> None:
42
+ """Create ``path`` 0700 if it is missing, else refuse it unless it is private already."""
43
+
44
+ try:
45
+ os.mkdir(path, _DIR_MODE)
46
+ except FileExistsError:
47
+ pass
48
+ except FileNotFoundError:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ os.mkdir(path, _DIR_MODE)
51
+ _require_private(path, os.lstat(path), stat.S_ISDIR, "directory", "chmod 700")
52
+
53
+
54
+ def require_private_files(directory: Path, names: Iterable[str]) -> None:
55
+ """Refuse any of ``directory``'s ``names`` that exists and is not private; skip the rest."""
56
+
57
+ for name in names:
58
+ path = directory / name
59
+ try:
60
+ status = os.lstat(path)
61
+ except FileNotFoundError:
62
+ continue
63
+ _require_private(path, status, stat.S_ISREG, "file", "chmod 600")
64
+
65
+
66
+ def private_write(path: Path, data: bytes) -> None:
67
+ """Replace ``path`` with ``data``: 0600 from the first byte, and all-or-nothing.
68
+
69
+ A temp file beside the target, created exclusively and without following a planted
70
+ symlink, then renamed over it: a reader sees the old content or the new, never a
71
+ truncated identity, and the rename is made durable by syncing the directory too.
72
+ """
73
+
74
+ ensure_private_dir(path.parent)
75
+ temporary = path.with_name(f".{path.name}.{secrets.token_hex(8)}.tmp")
76
+ descriptor = os.open(
77
+ temporary, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, _FILE_MODE
78
+ )
79
+ try:
80
+ with os.fdopen(descriptor, "wb") as handle:
81
+ handle.write(data)
82
+ handle.flush()
83
+ os.fsync(handle.fileno())
84
+ os.replace(temporary, path)
85
+ except BaseException:
86
+ with contextlib.suppress(FileNotFoundError):
87
+ os.unlink(temporary)
88
+ raise
89
+ directory = os.open(path.parent, os.O_RDONLY)
90
+ try:
91
+ os.fsync(directory)
92
+ finally:
93
+ os.close(directory)
94
+
95
+
96
+ def private_read(path: Path) -> bytes | None:
97
+ """``path``'s content, or ``None`` if it does not exist; refused unless private."""
98
+
99
+ parent = path.parent
100
+ try:
101
+ parent_status = os.lstat(parent)
102
+ except FileNotFoundError:
103
+ return None
104
+ _require_private(parent, parent_status, stat.S_ISDIR, "directory", "chmod 700")
105
+ try:
106
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
107
+ except FileNotFoundError:
108
+ return None
109
+ except OSError as error:
110
+ if error.errno == errno.ELOOP:
111
+ raise UnsafeStateError(f"{path} is a symlink; refusing to read through it") from error
112
+ raise
113
+ with os.fdopen(descriptor, "rb") as handle:
114
+ _require_private(path, os.fstat(handle.fileno()), stat.S_ISREG, "file", "chmod 600")
115
+ return handle.read()
116
+
117
+
118
+ def _require_private(
119
+ path: Path,
120
+ status: os.stat_result,
121
+ is_kind: Callable[[int], bool],
122
+ kind: str,
123
+ remedy: str,
124
+ ) -> None:
125
+ if stat.S_ISLNK(status.st_mode):
126
+ raise UnsafeStateError(f"{path} is a symlink; the state {kind} must be a real {kind}")
127
+ if not is_kind(status.st_mode):
128
+ raise UnsafeStateError(f"{path} is not a {kind}")
129
+ euid = os.geteuid()
130
+ if status.st_uid != euid:
131
+ raise UnsafeStateError(
132
+ f"{path} is owned by uid {status.st_uid}, not this Runner's uid {euid}"
133
+ )
134
+ if status.st_mode & _GROUP_OTHER_BITS:
135
+ raise UnsafeStateError(
136
+ f"{path} is mode {stat.S_IMODE(status.st_mode):04o}, open to group or others; "
137
+ f"`{remedy} {path}` if nobody else could have read it"
138
+ )
@@ -33,6 +33,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
33
33
  from pydantic import BaseModel, ConfigDict
34
34
 
35
35
  from agentic_runner import __version__ as runner_version
36
+ from agentic_runner.build import build_id
37
+ from agentic_runner.private_state import private_read, private_write
36
38
  from agentic_runner_contracts import __version__ as contracts_version
37
39
  from agentic_runner_contracts.channel_messages import TRANSCRIPT_DELIVERIES_PATH, TranscriptDelivery
38
40
  from agentic_runner_contracts.runner_registration import (
@@ -212,19 +214,12 @@ def _version(value: str) -> tuple[int, ...]:
212
214
  def load_state(state_dir: Path) -> RunnerState | None:
213
215
  """The persisted identity, or ``None`` on a first boot."""
214
216
 
215
- path = state_dir / STATE_FILENAME
216
- if not path.exists():
217
- return None
218
- return RunnerState.model_validate_json(path.read_text(encoding="utf-8"))
217
+ raw = private_read(state_dir / STATE_FILENAME)
218
+ return None if raw is None else RunnerState.model_validate_json(raw)
219
219
 
220
220
 
221
221
  def save_state(state_dir: Path, state: RunnerState) -> None:
222
- state_dir.mkdir(parents=True, exist_ok=True)
223
- path = state_dir / STATE_FILENAME
224
- path.write_text(state.model_dump_json(), encoding="utf-8")
225
- # The private key is in this file; a shared PVC or a workstation home is not a place
226
- # to leave it group-readable.
227
- path.chmod(0o600)
222
+ private_write(state_dir / STATE_FILENAME, state.model_dump_json().encode("utf-8"))
228
223
 
229
224
 
230
225
  class RunnerRegistrationClient:
@@ -258,6 +253,7 @@ class RunnerRegistrationClient:
258
253
  runner_version=runner_version,
259
254
  recipient_key=recipient_key,
260
255
  max_concurrent_directives=max_concurrent_directives,
256
+ build_id=build_id(),
261
257
  )
262
258
  payload = await self._post(BOOTSTRAP_PATH, content=request.model_dump_json(), headers={})
263
259
  response = BootstrapResponse.model_validate(payload)