agentic-runner 2.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentic_runner/__init__.py +12 -0
- agentic_runner/activities.py +4918 -0
- agentic_runner/callback.py +342 -0
- agentic_runner/child_watcher.py +66 -0
- agentic_runner/cli.py +416 -0
- agentic_runner/config.py +105 -0
- agentic_runner/credentials.py +252 -0
- agentic_runner/device_login_activities.py +79 -0
- agentic_runner/egress.py +243 -0
- agentic_runner/heartbeat_link.py +249 -0
- agentic_runner/hooks.py +455 -0
- agentic_runner/host_store.py +295 -0
- agentic_runner/integrations/__init__.py +0 -0
- agentic_runner/integrations/git/__init__.py +1 -0
- agentic_runner/integrations/git/contracts.py +198 -0
- agentic_runner/integrations/git/evidence.py +442 -0
- agentic_runner/integrations/git/fake_workspace.py +339 -0
- agentic_runner/integrations/git/workspace.py +921 -0
- agentic_runner/integrations/github/__init__.py +53 -0
- agentic_runner/integrations/github/auth.py +171 -0
- agentic_runner/integrations/github/fake_client.py +494 -0
- agentic_runner/integrations/github/gh_client.py +944 -0
- agentic_runner/lifecycle.py +48 -0
- agentic_runner/llm_proxy.py +937 -0
- agentic_runner/mcp.py +342 -0
- agentic_runner/message_store.py +341 -0
- agentic_runner/py.typed +0 -0
- agentic_runner/recipient_key_secret.py +134 -0
- agentic_runner/registration.py +363 -0
- agentic_runner/runtime/__init__.py +0 -0
- agentic_runner/runtime/verifier_command.py +344 -0
- agentic_runner/sealed_box.py +509 -0
- agentic_runner/service.py +1068 -0
- agentic_runner/tiny_http.py +133 -0
- agentic_runner/triage_activities.py +113 -0
- agentic_runner/user_sources.py +546 -0
- agentic_runner/workers/__init__.py +1 -0
- agentic_runner/workers/_runtime_support.py +388 -0
- agentic_runner/workers/agent_runtime.py +93 -0
- agentic_runner/workers/claude_runtime.py +226 -0
- agentic_runner/workers/codex_runtime.py +311 -0
- agentic_runner/workers/command_policy.py +250 -0
- agentic_runner/workers/contract_device_login.py +211 -0
- agentic_runner/workers/contract_isolation.py +500 -0
- agentic_runner/workers/fastapi_client.py +396 -0
- agentic_runner/workers/harness_usage.py +65 -0
- agentic_runner/workers/mcp_config.py +111 -0
- agentic_runner/workers/settings.py +314 -0
- agentic_runner/workstation.py +687 -0
- agentic_runner-2.6.0.dist-info/METADATA +49 -0
- agentic_runner-2.6.0.dist-info/RECORD +54 -0
- agentic_runner-2.6.0.dist-info/WHEEL +4 -0
- agentic_runner-2.6.0.dist-info/entry_points.txt +2 -0
- agentic_runner-2.6.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,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)
|