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,500 @@
1
+ """One OS uid per Contract on the Runner (ADR-0015 §1-§3, PRD issue 30).
2
+
3
+ The Runner process keeps its own uid and is the only thing that touches a credential
4
+ value. Everything that executes repository code or runs beside an Agent — the Directive
5
+ subprocess, the verifier, later Runner Hooks and stdio MCP servers — is spawned as the
6
+ Contract's own unprivileged uid, inside a Contract directory no other Contract's uid can
7
+ read.
8
+
9
+ Layout under ``WORKSPACE_ROOT`` (the whole of what a termination wipe deletes)::
10
+
11
+ {contract_id}/ 0700, the Contract's uid
12
+ {contract_id}/{work_record_id} the Workspace (ADR-0015 §2)
13
+ {contract_id}/harness/{runtime_kind} CODEX_HOME / CLAUDE_CONFIG_DIR (ADR-0015 §4)
14
+ {contract_id}/tmp TMPDIR (ADR-0015 §4)
15
+
16
+ Everything here lives in the worker tree so M3 (issue 36) moves it into ``agentic-runner``
17
+ unchanged: it imports nothing from the platform's services or database.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fcntl
23
+ import json
24
+ import os
25
+ import re
26
+ import resource
27
+ import shutil
28
+ import stat
29
+ from collections.abc import Callable, Iterator
30
+ from contextlib import contextmanager
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any, Final
34
+ from uuid import UUID
35
+
36
+ from agentic_runner.integrations.git.workspace import contract_workspace_path
37
+
38
+ __all__ = [
39
+ "NO_CONTRACT",
40
+ "ContractIsolation",
41
+ "ContractIsolationError",
42
+ "ContractResidue",
43
+ "DirectiveSandbox",
44
+ "contract_path_segment",
45
+ ]
46
+
47
+ # A Work Record with no Contract bound — every row predating issue 08's backfill, and any
48
+ # flow that binds none. It gets its own directory rather than a share of somebody else's,
49
+ # but no uid: there is no Contract to be the OS line, and refusing the run would stop the
50
+ # platform that is deploying the Contract layer (the same posture grant enforcement takes
51
+ # for an Agent-less Work Record).
52
+ NO_CONTRACT: Final[str] = "no-contract"
53
+
54
+ _HARNESS_DIR: Final[str] = "harness"
55
+ _TMP_DIR: Final[str] = "tmp"
56
+ # Everything directly under a Contract's directory that is not one of its Workspaces.
57
+ _RESERVED_DIRS: Final[frozenset[str]] = frozenset({_HARNESS_DIR, _TMP_DIR})
58
+ # The Runner's own git metadata inside a checkout. Never handed to the Contract: see
59
+ # `hand_workspace_to_contract`.
60
+ _GIT_DIR: Final[str] = ".git"
61
+ # Read by the Contract's git, not the Runner's (whose HOME is the credential-bearing
62
+ # `.agentic-os-git-home`), so it only ever relaxes a check for the Contract's own uid.
63
+ _CONTRACT_GITCONFIG: Final[str] = ".gitconfig"
64
+ _CONTRACT_GITCONFIG_BODY: Final[str] = "[safe]\n\tdirectory = *\n"
65
+ _UID_MAP_FILE: Final[str] = "contract-uids.json"
66
+ _UID_LOCK_FILE: Final[str] = "contract-uids.lock"
67
+ # Path segments are platform ids (map ticket 07), i.e. UUIDs — plus the sentinel above.
68
+ _PATH_SEGMENT_RE: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
69
+ _RUNTIME_KIND_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
70
+
71
+ _DIR_MODE: Final[int] = 0o700
72
+
73
+
74
+ class ContractIsolationError(RuntimeError):
75
+ """Raised when a Contract's uid or directory cannot be established."""
76
+
77
+
78
+ def _is_uuid(name: str) -> bool:
79
+ try:
80
+ UUID(name)
81
+ except ValueError:
82
+ return False
83
+ return True
84
+
85
+
86
+ def _deny_group_and_other_writes(git_dir: Path) -> None:
87
+ """Root ownership of ``.git`` refuses nothing while its entries are world-writable.
88
+
89
+ git creates every ``.git`` entry at ``0777 & ~umask``, so the Runner's umask decides
90
+ whether the Contract can plant a ``pre-commit`` hook. The ARC runner container runs
91
+ jobs with umask 000, which is how ``test_contract_uid_isolation`` first caught it;
92
+ a Runner process started the same way in production would have the same hole.
93
+ """
94
+
95
+ if git_dir.is_symlink():
96
+ return
97
+ entries: list[Path] = [git_dir] if git_dir.exists() else []
98
+ if git_dir.is_dir():
99
+ for directory, _dirnames, filenames in os.walk(git_dir):
100
+ entries.extend(Path(directory) / filename for filename in filenames)
101
+ entries.extend(Path(directory) / name for name in _dirnames)
102
+ for entry in entries:
103
+ if entry.is_symlink():
104
+ continue
105
+ mode = stat.S_IMODE(entry.lstat().st_mode)
106
+ if mode & 0o022:
107
+ entry.chmod(mode & ~0o022)
108
+
109
+
110
+ def contract_path_segment(contract_id: str | None) -> str:
111
+ """The directory name a Contract's tree lives under, validated as a path segment."""
112
+
113
+ segment = (contract_id or "").strip() or NO_CONTRACT
114
+ if not _PATH_SEGMENT_RE.fullmatch(segment):
115
+ raise ContractIsolationError(f"contract id is not a safe path segment: {contract_id!r}")
116
+ return segment
117
+
118
+
119
+ @dataclass(frozen=True, slots=True)
120
+ class DirectiveSandbox:
121
+ """Where a Contract's subprocess lives and what it may not exceed (ADR-0015 §1).
122
+
123
+ ``uid`` is None on a Runner that cannot separate uids (a workstation, or a container
124
+ without ``CAP_SETUID``): the directories are still 0700 and per Contract, but the
125
+ process runs as the Runner's own user. ADR-0015 §5 turns that into a declared,
126
+ routing-visible single-Contract mode in M3; M1 only has to not pretend otherwise.
127
+ """
128
+
129
+ home_dir: Path
130
+ harness_config_dir: Path
131
+ max_processes: int
132
+ max_memory_bytes: int
133
+ uid: int | None = None
134
+ gid: int | None = None
135
+
136
+ @property
137
+ def tmp_dir(self) -> Path:
138
+ """This Contract's ``TMPDIR``.
139
+
140
+ The pod's ``/tmp`` is one emptyDir every Contract uid can write and list, so a
141
+ harness CLI's temp files would leak across the line the per-Contract harness root
142
+ draws. Inside the Contract's own 0700 tree they do not.
143
+ """
144
+
145
+ return self.home_dir / _TMP_DIR
146
+
147
+ def spawn_kwargs(self) -> dict[str, Any]:
148
+ """What ``subprocess`` needs to put one spawn under this Contract's floor.
149
+
150
+ The uid/gid drop is handed to ``subprocess``'s own fork-exec path (``user`` /
151
+ ``group`` / ``extra_groups``), which does it in C: ``preexec_fn`` runs Python
152
+ between fork and exec in a Temporal worker that has real threads, which CPython
153
+ documents as unsafe, so only what has no C equivalent — the two rlimits — is left
154
+ in the hook.
155
+ """
156
+
157
+ kwargs: dict[str, Any] = {"preexec_fn": self.preexec()}
158
+ if self.gid is not None:
159
+ # Ordered by subprocess itself: setgroups, then setgid, then setuid.
160
+ kwargs["extra_groups"] = []
161
+ kwargs["group"] = self.gid
162
+ if self.uid is not None:
163
+ kwargs["user"] = self.uid
164
+ return kwargs
165
+
166
+ def preexec(self) -> Callable[[], None]:
167
+ """The between-fork-and-exec hook that applies the rlimit floor (ADR-0011 §12).
168
+
169
+ Not attenuable: it is applied by the Runner to every spawn, after the command
170
+ policy has already accepted the argv, and no Grant reaches it. It runs *after*
171
+ the uid drop `spawn_kwargs` asks subprocess for, so both limits land on the
172
+ Contract's uid; lowering a soft and hard limit needs no privilege.
173
+
174
+ The memory ceiling is ``RLIMIT_DATA``, not ``RLIMIT_AS``: both harness CLIs run on
175
+ node, and V8 *reserves* a multi-gigabyte pointer-compression cage of PROT_NONE
176
+ address space at start-up. An ``RLIMIT_AS`` of a few GiB kills node before it runs
177
+ a single turn, while ``RLIMIT_DATA`` counts only writable private mappings — the
178
+ heap that actually grows — so the ceiling bites on the runaway and not on start-up.
179
+ """
180
+
181
+ max_processes = self.max_processes
182
+ max_memory_bytes = self.max_memory_bytes
183
+
184
+ def apply_floor() -> None:
185
+ resource.setrlimit(resource.RLIMIT_NPROC, (max_processes, max_processes))
186
+ resource.setrlimit(resource.RLIMIT_DATA, (max_memory_bytes, max_memory_bytes))
187
+
188
+ return apply_floor
189
+
190
+
191
+ @dataclass(frozen=True, slots=True)
192
+ class ContractResidue:
193
+ """What a termination wipe removed — ids and counts only (ADR-0015 §2, issue 30)."""
194
+
195
+ contract_id: str
196
+ workspaces_removed: int
197
+ harness_roots_removed: int
198
+ uid_retired: bool
199
+
200
+
201
+ class ContractIsolation:
202
+ """Allocates one uid per Contract and owns that Contract's directory tree.
203
+
204
+ The uid map is persisted in the Runner's own state directory so a restart reuses the
205
+ uid a Contract's files are already owned by. No passwd entry is created: nothing here
206
+ needs name resolution, and a Runner has no business editing ``/etc/passwd``.
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ *,
212
+ workspace_root: Path,
213
+ state_dir: Path,
214
+ uid_min: int,
215
+ uid_max: int,
216
+ max_processes: int,
217
+ memory_limit_bytes: int,
218
+ can_separate_uids: bool | None = None,
219
+ ) -> None:
220
+ if uid_min <= 0 or uid_max < uid_min:
221
+ raise ValueError("contract uid range must be a positive, non-empty range")
222
+ self._workspace_root = workspace_root.resolve(strict=False)
223
+ self._state_dir = state_dir.resolve(strict=False)
224
+ self._uid_min = uid_min
225
+ self._uid_max = uid_max
226
+ self._max_processes = max_processes
227
+ self._memory_limit_bytes = memory_limit_bytes
228
+ # Detected, not declared: ADR-0015 §5's `isolation:` setting and the routing rule
229
+ # that makes a `none` Runner single-Contract are M3 (issue 42). Until then the
230
+ # honest answer is whether this process can actually change uid.
231
+ self._can_separate_uids = (
232
+ os.geteuid() == 0 if can_separate_uids is None else can_separate_uids
233
+ )
234
+
235
+ @property
236
+ def can_separate_uids(self) -> bool:
237
+ return self._can_separate_uids
238
+
239
+ def contract_dir(self, contract_id: str | None) -> Path:
240
+ return self._workspace_root / contract_path_segment(contract_id)
241
+
242
+ def workspace_path(self, contract_id: str | None, work_record_id: str) -> Path:
243
+ """``{contract_id}/{work_record_id}`` — one Workspace per Work Record (17 A5)."""
244
+
245
+ return contract_workspace_path(
246
+ workspace_root=self._workspace_root,
247
+ contract_id=contract_path_segment(contract_id),
248
+ work_record_id=work_record_id,
249
+ )
250
+
251
+ def harness_config_dir(self, contract_id: str | None, runtime_kind: str) -> Path:
252
+ if not _RUNTIME_KIND_RE.fullmatch(runtime_kind):
253
+ raise ContractIsolationError(f"runtime kind is not a safe segment: {runtime_kind!r}")
254
+ return self.contract_dir(contract_id) / _HARNESS_DIR / runtime_kind
255
+
256
+ def has_uid(self, contract_id: str | None) -> bool:
257
+ """Whether this Contract already holds a uid.
258
+
259
+ Read before ``uid_for`` by the caller that records the allocation as an Evidence
260
+ Event, so only the call that actually allocates writes one (PRD issue 30).
261
+ """
262
+
263
+ segment = contract_path_segment(contract_id)
264
+ if not self._can_separate_uids or segment == NO_CONTRACT:
265
+ return False
266
+ return segment in self._read_uid_map()
267
+
268
+ def uid_for(self, contract_id: str | None) -> int | None:
269
+ """This Contract's uid, allocated from the Runner-local range on first sight."""
270
+
271
+ segment = contract_path_segment(contract_id)
272
+ if not self._can_separate_uids or segment == NO_CONTRACT:
273
+ return None
274
+ with self._uid_map_locked():
275
+ allocated = self._read_uid_map()
276
+ existing = allocated.get(segment)
277
+ if existing is not None:
278
+ return existing
279
+ taken = set(allocated.values())
280
+ for candidate in range(self._uid_min, self._uid_max + 1):
281
+ if candidate not in taken:
282
+ allocated[segment] = candidate
283
+ self._write_uid_map(allocated)
284
+ return candidate
285
+ raise ContractIsolationError(
286
+ f"contract uid range {self._uid_min}-{self._uid_max} is exhausted"
287
+ )
288
+
289
+ def sandbox(
290
+ self,
291
+ contract_id: str | None,
292
+ *,
293
+ runtime_kind: str,
294
+ memory_limit_bytes: int | None = None,
295
+ ) -> DirectiveSandbox:
296
+ """Prepare the Contract's harness root and describe the floor its spawns run under."""
297
+
298
+ uid = self.uid_for(contract_id)
299
+ home = self._ensure_dir(self.contract_dir(contract_id), uid)
300
+ harness = self._ensure_dir(self.harness_config_dir(contract_id, runtime_kind), uid)
301
+ self._ensure_dir(home / _TMP_DIR, uid)
302
+ self._write_contract_gitconfig(home, uid)
303
+ return DirectiveSandbox(
304
+ home_dir=home,
305
+ harness_config_dir=harness,
306
+ max_processes=self._max_processes,
307
+ max_memory_bytes=memory_limit_bytes or self._memory_limit_bytes,
308
+ uid=uid,
309
+ gid=uid,
310
+ )
311
+
312
+ def prepare_workspace(self, contract_id: str | None, work_record_id: str) -> Path:
313
+ """Create ``{contract_id}/{work_record_id}`` 0700, owned by the Contract's uid."""
314
+
315
+ uid = self.uid_for(contract_id)
316
+ self._ensure_dir(self.contract_dir(contract_id), uid)
317
+ return self._ensure_dir(self.workspace_path(contract_id, work_record_id), uid)
318
+
319
+ def hand_workspace_to_contract(self, contract_id: str | None, workspace_path: Path) -> None:
320
+ """Re-own the checkout before a Directive runs in it — everything but ``.git``.
321
+
322
+ The Runner clones, fetches, commits and pushes as its own uid (it is the only
323
+ thing that may touch the git credential), so every git write lands root-owned in
324
+ a Contract-owned tree. The Directive that runs next is the Contract's uid and has
325
+ to be able to write what git just wrote.
326
+
327
+ ``.git`` is deliberately left out of that hand-over. It is the one part of a
328
+ checkout the Runner's own git reads as *instructions* rather than as data: a
329
+ Contract that could write it would put a ``pre-commit`` hook in ``.git/hooks/``,
330
+ or ``core.fsmonitor`` / ``core.hooksPath`` / ``filter.*.clean`` in
331
+ ``.git/config``, and the Runner's next ``git status`` / ``git add`` /
332
+ ``git commit`` would run that command as the Runner — with CAP_SETUID, CAP_CHOWN
333
+ and CAP_DAC_OVERRIDE, i.e. with every other Contract's tree, the git credential
334
+ and the uid map. Naming the settings in ``-c`` flags does not close it
335
+ (``filter.*`` and ``diff.*.textconv`` are driven by a committed
336
+ ``.gitattributes`` and are not enumerable), so the directory itself stays the
337
+ Runner's. Left root-owned it is still readable, so the Directive's own
338
+ ``git status`` / ``git diff`` / ``git log`` work; only writing is refused, and the
339
+ Runner already owns the commit.
340
+
341
+ Ownership of ``.git`` is **not** on its own the boundary, and this function does
342
+ not claim to be one. The Contract owns the directory that *contains* ``.git``,
343
+ and on POSIX renaming or creating an entry is governed by write+execute on the
344
+ parent — so a Directive can move the Runner's ``.git`` aside and drop a replica
345
+ of its own in place. What closes it is
346
+ ``integrations.git.workspace.require_runner_owned_git_dir``, re-checking that
347
+ ``.git`` is still a Runner-owned directory before every Runner git call into a
348
+ Workspace. Keeping the chown off ``.git`` is what makes that check cheap and
349
+ never false-positive; the check is what makes it hold.
350
+
351
+ ponytail: a full-tree chown before each Directive. Cheap next to a clone, but it
352
+ is O(files) per Directive — revisit with a shared supplementary group if a large
353
+ monorepo makes it show up.
354
+ """
355
+
356
+ uid = self.uid_for(contract_id)
357
+ if uid is None:
358
+ return
359
+ resolved = workspace_path.resolve(strict=False)
360
+ if self._workspace_root not in resolved.parents:
361
+ raise ContractIsolationError("workspace_path must be under the workspace root")
362
+ os.chown(resolved, uid, uid)
363
+ for directory, dirnames, filenames in os.walk(resolved):
364
+ dirnames[:] = [name for name in dirnames if name != _GIT_DIR]
365
+ os.chown(directory, uid, uid)
366
+ for filename in filenames:
367
+ entry = Path(directory) / filename
368
+ if not entry.is_symlink():
369
+ os.chown(entry, uid, uid)
370
+ _deny_group_and_other_writes(resolved / _GIT_DIR)
371
+
372
+ def wipe(self, contract_id: str | None) -> ContractResidue:
373
+ """Delete the Contract's tree and retire its uid (17 A6). Idempotent."""
374
+
375
+ segment = contract_path_segment(contract_id)
376
+ contract_dir = self.contract_dir(segment)
377
+ workspaces = 0
378
+ harness_roots = 0
379
+ harness_dir = contract_dir / _HARNESS_DIR
380
+ if contract_dir.is_dir():
381
+ if harness_dir.is_dir():
382
+ harness_roots = sum(1 for entry in harness_dir.iterdir() if entry.is_dir())
383
+ workspaces = sum(
384
+ 1
385
+ for entry in contract_dir.iterdir()
386
+ if entry.is_dir() and entry.name not in _RESERVED_DIRS
387
+ )
388
+ shutil.rmtree(contract_dir, ignore_errors=True)
389
+ if contract_dir.exists():
390
+ # A partial wipe is not a wipe. Report nothing removed and keep the uid:
391
+ # a terminated Contract's Evidence must not claim a tree that is still on
392
+ # disk, and the files left behind are still owned by that uid, so handing
393
+ # it to the next Contract would hand over their contents with it.
394
+ return ContractResidue(
395
+ contract_id=segment,
396
+ workspaces_removed=0,
397
+ harness_roots_removed=0,
398
+ uid_retired=False,
399
+ )
400
+ with self._uid_map_locked():
401
+ allocated = self._read_uid_map()
402
+ uid_retired = allocated.pop(segment, None) is not None
403
+ if uid_retired:
404
+ self._write_uid_map(allocated)
405
+ return ContractResidue(
406
+ contract_id=segment,
407
+ workspaces_removed=workspaces,
408
+ harness_roots_removed=harness_roots,
409
+ uid_retired=uid_retired,
410
+ )
411
+
412
+ def held_work_record_ids(self) -> list[tuple[str, str]]:
413
+ """Every ``(contract_id, work_record_id)`` Workspace currently on this Runner.
414
+
415
+ Only directories that are really Work Record ids. Both callers send the names on
416
+ to the control plane as ``UUID``s, and one stray directory under one Contract
417
+ would otherwise 422 the whole daily retention sweep — every other Contract's
418
+ expired Workspaces with it — rather than just being skipped.
419
+ """
420
+
421
+ if not self._workspace_root.is_dir():
422
+ return []
423
+ held: list[tuple[str, str]] = []
424
+ for contract_dir in sorted(self._workspace_root.iterdir()):
425
+ if not contract_dir.is_dir() or not _PATH_SEGMENT_RE.fullmatch(contract_dir.name):
426
+ continue
427
+ for entry in sorted(contract_dir.iterdir()):
428
+ if entry.is_dir() and entry.name not in _RESERVED_DIRS and _is_uuid(entry.name):
429
+ held.append((contract_dir.name, entry.name))
430
+ return held
431
+
432
+ def remove_workspace(self, contract_id: str | None, work_record_id: str) -> bool:
433
+ """Delete one Work Record's Workspace, leaving the Contract's tree standing."""
434
+
435
+ path = self.workspace_path(contract_id, work_record_id)
436
+ if not path.is_dir():
437
+ return False
438
+ shutil.rmtree(path, ignore_errors=True)
439
+ return not path.exists()
440
+
441
+ def _ensure_dir(self, path: Path, uid: int | None) -> Path:
442
+ path.mkdir(parents=True, exist_ok=True)
443
+ path.chmod(_DIR_MODE)
444
+ if uid is not None:
445
+ os.chown(path, uid, uid)
446
+ return path
447
+
448
+ def _write_contract_gitconfig(self, home: Path, uid: int | None) -> None:
449
+ """Let the Contract's own git read the checkout it does not own.
450
+
451
+ ``.git`` stays the Runner's (see `hand_workspace_to_contract`), and git refuses a
452
+ repository whose gitdir another user owns. Without this the Directive's own
453
+ ``git status`` / ``git diff`` fails on "dubious ownership". It relaxes nothing:
454
+ the config is read only by git running as the Contract's uid, which gains no
455
+ access it did not already have.
456
+ """
457
+
458
+ config = home / _CONTRACT_GITCONFIG
459
+ if config.is_file() and config.read_text(encoding="utf-8") == _CONTRACT_GITCONFIG_BODY:
460
+ return
461
+ config.write_text(_CONTRACT_GITCONFIG_BODY, encoding="utf-8")
462
+ config.chmod(0o600)
463
+ if uid is not None:
464
+ os.chown(config, uid, uid)
465
+
466
+ @contextmanager
467
+ def _uid_map_locked(self) -> Iterator[None]:
468
+ """Serialise the uid map's read-modify-write across processes.
469
+
470
+ Within one worker the allocation is already atomic (no ``await`` in ``uid_for``),
471
+ but two Runner replicas sharing the state volume would otherwise hand two
472
+ Contracts the same uid — and one uid is the whole isolation boundary.
473
+ """
474
+
475
+ self._state_dir.mkdir(parents=True, exist_ok=True)
476
+ with (self._state_dir / _UID_LOCK_FILE).open("w") as handle:
477
+ fcntl.flock(handle, fcntl.LOCK_EX)
478
+ yield
479
+
480
+ def _uid_map_path(self) -> Path:
481
+ return self._state_dir / _UID_MAP_FILE
482
+
483
+ def _read_uid_map(self) -> dict[str, int]:
484
+ path = self._uid_map_path()
485
+ if not path.is_file():
486
+ return {}
487
+ loaded = json.loads(path.read_text())
488
+ if not isinstance(loaded, dict):
489
+ raise ContractIsolationError(f"contract uid map at {path} is not an object")
490
+ return {str(key): int(value) for key, value in loaded.items()}
491
+
492
+ def _write_uid_map(self, allocated: dict[str, int]) -> None:
493
+ self._state_dir.mkdir(parents=True, exist_ok=True)
494
+ path = self._uid_map_path()
495
+ # Atomic: a crash mid-write must not leave a half-parsed map that would hand a
496
+ # second Contract a uid another Contract's files are already owned by.
497
+ temporary = path.with_suffix(".tmp")
498
+ temporary.write_text(json.dumps(allocated, sort_keys=True))
499
+ temporary.chmod(0o600)
500
+ temporary.replace(path)