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,921 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
from uuid import UUID
|
|
13
|
+
|
|
14
|
+
from agentic_runner.integrations.git.contracts import (
|
|
15
|
+
AdoptWorkspaceRequest,
|
|
16
|
+
CheckoutWorkBranchRequest,
|
|
17
|
+
CheckoutWorkBranchResult,
|
|
18
|
+
CloneWorkspaceRequest,
|
|
19
|
+
CloneWorkspaceResult,
|
|
20
|
+
CommitAllRequest,
|
|
21
|
+
CommitAllResult,
|
|
22
|
+
FetchBranchRequest,
|
|
23
|
+
FetchBranchResult,
|
|
24
|
+
PrepareVerifierWorkspaceRequest,
|
|
25
|
+
PrepareVerifierWorkspaceResult,
|
|
26
|
+
PushBranchRequest,
|
|
27
|
+
PushBranchResult,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from agentic_runner.integrations.git.evidence import GitEvidence
|
|
32
|
+
|
|
33
|
+
PROTECTED_BRANCHES = frozenset(("main", "master"))
|
|
34
|
+
_GENERATED_WORK_BRANCH_RE = re.compile(
|
|
35
|
+
r"^agent/(?P<work_record_id>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-"
|
|
36
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})-"
|
|
37
|
+
r"(?P<slug>[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)$"
|
|
38
|
+
)
|
|
39
|
+
_UNSAFE_BRANCH_TOKENS = (
|
|
40
|
+
"..",
|
|
41
|
+
" ",
|
|
42
|
+
":",
|
|
43
|
+
"+",
|
|
44
|
+
"~",
|
|
45
|
+
"^",
|
|
46
|
+
"?",
|
|
47
|
+
"*",
|
|
48
|
+
"[",
|
|
49
|
+
"\\",
|
|
50
|
+
"refs/tags",
|
|
51
|
+
)
|
|
52
|
+
_BASE_BRANCH_ALLOWED_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
|
|
53
|
+
_COMMIT_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
54
|
+
_REPO_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
55
|
+
_WORKSPACE_ID_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
|
56
|
+
_TOKEN_ENV_KEY = "AGENTIC_OS_GIT_TOKEN"
|
|
57
|
+
_ASKPASS_SCRIPT = """#!/bin/sh
|
|
58
|
+
case "$1" in
|
|
59
|
+
*Username*) printf '%s\\n' 'x-access-token' ;;
|
|
60
|
+
*) printf '%s\\n' "${AGENTIC_OS_GIT_TOKEN}" ;;
|
|
61
|
+
esac
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GitWorkspacePolicyError(ValueError):
|
|
66
|
+
"""Raised when a workspace or branch violates local git safety policy."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GitWorkspaceCommandError(RuntimeError):
|
|
70
|
+
"""Raised when a sanitized local git subprocess fails."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class GitCommandResult:
|
|
75
|
+
"""Sanitized, bounded public record of one git subprocess invocation."""
|
|
76
|
+
|
|
77
|
+
argv: tuple[str, ...]
|
|
78
|
+
cwd: Path | None
|
|
79
|
+
returncode: int
|
|
80
|
+
stdout: str
|
|
81
|
+
stderr: str
|
|
82
|
+
timed_out: bool = False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class _LocalWorkspaceRecord:
|
|
87
|
+
repo_full_name: str
|
|
88
|
+
remote_url: str
|
|
89
|
+
workspace_root: Path
|
|
90
|
+
workspace_path: Path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def validate_work_branch(work_branch: str) -> str:
|
|
94
|
+
"""Validate that a branch is a generated WorkRecord branch, not a refspec."""
|
|
95
|
+
|
|
96
|
+
if work_branch in PROTECTED_BRANCHES:
|
|
97
|
+
raise GitWorkspacePolicyError(
|
|
98
|
+
f"Protected branch '{work_branch}' cannot be used as work branch"
|
|
99
|
+
)
|
|
100
|
+
if work_branch in {"--all", "--mirror"} or work_branch.startswith("-"):
|
|
101
|
+
raise GitWorkspacePolicyError(f"Refspec-like branch '{work_branch}' is not allowed")
|
|
102
|
+
if work_branch.startswith(("/", ".")) or work_branch.endswith(("/", ".")):
|
|
103
|
+
raise GitWorkspacePolicyError(f"Unsafe branch boundary in '{work_branch}'")
|
|
104
|
+
if any(token in work_branch for token in _UNSAFE_BRANCH_TOKENS):
|
|
105
|
+
raise GitWorkspacePolicyError(f"Unsafe branch content in '{work_branch}'")
|
|
106
|
+
|
|
107
|
+
match = _GENERATED_WORK_BRANCH_RE.fullmatch(work_branch)
|
|
108
|
+
if match is None:
|
|
109
|
+
raise GitWorkspacePolicyError(
|
|
110
|
+
"Work branch must match 'agent/<work_record_id>-<slug>' with a UUID work_record_id"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
UUID(match.group("work_record_id"))
|
|
114
|
+
return work_branch
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_base_branch(base_branch: str) -> str:
|
|
118
|
+
"""Validate that a base branch is a safe short branch name, not a ref/revision."""
|
|
119
|
+
|
|
120
|
+
if base_branch == "":
|
|
121
|
+
raise GitWorkspacePolicyError("Base branch cannot be empty")
|
|
122
|
+
if not _BASE_BRANCH_ALLOWED_RE.fullmatch(base_branch):
|
|
123
|
+
raise GitWorkspacePolicyError(f"Unsafe base branch content in '{base_branch}'")
|
|
124
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in base_branch):
|
|
125
|
+
raise GitWorkspacePolicyError(f"Unsafe base branch control character in '{base_branch}'")
|
|
126
|
+
if base_branch in {"--all", "--mirror"} or base_branch.startswith("-"):
|
|
127
|
+
raise GitWorkspacePolicyError(f"Refspec-like base branch '{base_branch}' is not allowed")
|
|
128
|
+
if base_branch == "HEAD" or base_branch.startswith("refs/"):
|
|
129
|
+
raise GitWorkspacePolicyError(f"Ref-like base branch '{base_branch}' is not allowed")
|
|
130
|
+
if base_branch.startswith(("/", ".")) or base_branch.endswith(("/", ".")):
|
|
131
|
+
raise GitWorkspacePolicyError(f"Unsafe base branch boundary in '{base_branch}'")
|
|
132
|
+
if any(token in base_branch for token in ("..", "//")):
|
|
133
|
+
raise GitWorkspacePolicyError(f"Unsafe base branch content in '{base_branch}'")
|
|
134
|
+
if any(component.startswith(".") for component in base_branch.split("/")):
|
|
135
|
+
raise GitWorkspacePolicyError(f"Unsafe base branch path component in '{base_branch}'")
|
|
136
|
+
if any(component.endswith(".lock") for component in base_branch.split("/")):
|
|
137
|
+
raise GitWorkspacePolicyError(f"Unsafe base branch lock suffix in '{base_branch}'")
|
|
138
|
+
|
|
139
|
+
return base_branch
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def validate_push_branch(
|
|
143
|
+
*,
|
|
144
|
+
work_branch: str,
|
|
145
|
+
base_branch: str,
|
|
146
|
+
protected_branches: tuple[str, ...] = (),
|
|
147
|
+
) -> str:
|
|
148
|
+
"""Reject pushes to protected or request-base branches before building a refspec."""
|
|
149
|
+
|
|
150
|
+
protected = PROTECTED_BRANCHES | frozenset(protected_branches) | {base_branch}
|
|
151
|
+
if work_branch in protected:
|
|
152
|
+
raise GitWorkspacePolicyError(f"Protected branch '{work_branch}' cannot be pushed")
|
|
153
|
+
return validate_work_branch(work_branch)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def validate_commit_sha(commit_sha: str) -> str:
|
|
157
|
+
"""Validate a full commit SHA before using it as a verifier reset target."""
|
|
158
|
+
|
|
159
|
+
if _COMMIT_SHA_RE.fullmatch(commit_sha) is None:
|
|
160
|
+
raise GitWorkspacePolicyError("Approved head must be a full 40-character commit SHA")
|
|
161
|
+
return commit_sha.lower()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def build_push_refspec(work_branch: str) -> str:
|
|
165
|
+
"""Build the only allowed push refspec for generated work branches."""
|
|
166
|
+
|
|
167
|
+
safe_branch = validate_work_branch(work_branch)
|
|
168
|
+
return f"HEAD:refs/heads/{safe_branch}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def contract_workspace_path(
|
|
172
|
+
*,
|
|
173
|
+
workspace_root: Path,
|
|
174
|
+
contract_id: str,
|
|
175
|
+
work_record_id: str,
|
|
176
|
+
) -> Path:
|
|
177
|
+
"""``WORKSPACE_ROOT/{contract_id}/{work_record_id}`` (ADR-0015 §2, PRD issue 30).
|
|
178
|
+
|
|
179
|
+
The Contract owns the Workspace, not the repository: two Contracts' Work Records on
|
|
180
|
+
one repo run concurrently on their own branches, and there is no repository lock —
|
|
181
|
+
conflicts surface at the pull request, as between two human contractors. Replaces the
|
|
182
|
+
``{owner}/{repo}/{work_record_id}`` layout, which keyed the tree on something that is
|
|
183
|
+
neither a platform id nor an isolation boundary.
|
|
184
|
+
|
|
185
|
+
Both segments are platform ids (map ticket 07); anything else is refused here rather
|
|
186
|
+
than allowed to walk out of the workspace root.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
for label, segment in (("contract id", contract_id), ("work record id", work_record_id)):
|
|
190
|
+
if not _WORKSPACE_ID_SEGMENT_RE.fullmatch(segment):
|
|
191
|
+
raise GitWorkspacePolicyError(f"{label} is not a safe path segment: {segment!r}")
|
|
192
|
+
return workspace_root / contract_id / work_record_id
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
GIT_METADATA_DIR = ".git"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def require_runner_owned_git_dir(workspace_path: Path) -> None:
|
|
199
|
+
"""Refuse to run the Runner's git over a ``.git`` the Contract could have replaced.
|
|
200
|
+
|
|
201
|
+
``workers/contract_isolation.hand_workspace_to_contract`` keeps ``.git`` owned by the
|
|
202
|
+
Runner, and that is *not* by itself the boundary: the Contract owns the Workspace
|
|
203
|
+
directory containing it, and on POSIX rename/create/unlink of an entry is governed by
|
|
204
|
+
write+execute on the parent, never by the entry's own ownership. A Directive can
|
|
205
|
+
therefore ``mv .git .git.stash; cp -r .git.stash .git`` and end up owning a ``.git``
|
|
206
|
+
of its own carrying an arbitrary ``hooks/pre-commit``, ``core.fsmonitor`` or
|
|
207
|
+
``filter.*.clean`` — all of which the Runner's next ``status``/``add``/``commit``
|
|
208
|
+
would execute as the Runner, with CAP_SETUID, CAP_CHOWN and CAP_DAC_OVERRIDE. The
|
|
209
|
+
ownership check that would have caught it is exactly the one ``-c safe.directory``
|
|
210
|
+
suppresses, so it is re-made here, on every Runner git call into a Workspace.
|
|
211
|
+
|
|
212
|
+
A ``lstat``, so a ``.git`` symlinked or replaced by a gitfile pointing at a tree the
|
|
213
|
+
Contract owns is refused too. There is no TOCTOU window worth chasing: the Runner's
|
|
214
|
+
git steps run between Directives, with none of that Contract's processes executing.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
git_dir = workspace_path / GIT_METADATA_DIR
|
|
218
|
+
try:
|
|
219
|
+
entry = os.lstat(git_dir)
|
|
220
|
+
except OSError as error:
|
|
221
|
+
raise GitWorkspacePolicyError(
|
|
222
|
+
f"Git metadata '{git_dir}' is missing or unreadable"
|
|
223
|
+
) from error
|
|
224
|
+
if not stat.S_ISDIR(entry.st_mode) or entry.st_uid != os.geteuid():
|
|
225
|
+
raise GitWorkspacePolicyError(
|
|
226
|
+
f"Git metadata '{git_dir}' is not a directory owned by the Runner "
|
|
227
|
+
f"(uid {entry.st_uid}, mode {entry.st_mode:#o}): the Workspace was tampered with"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def resolve_workspace_path(workspace_root: Path, workspace_path: Path) -> Path:
|
|
232
|
+
"""Resolve a workspace path and ensure it stays inside the worker workspace root."""
|
|
233
|
+
|
|
234
|
+
resolved_root = workspace_root.resolve()
|
|
235
|
+
resolved_path = workspace_path.resolve()
|
|
236
|
+
if resolved_path != resolved_root and resolved_root not in resolved_path.parents:
|
|
237
|
+
raise GitWorkspacePolicyError(
|
|
238
|
+
f"Workspace path '{resolved_path}' escapes worker workspace root '{resolved_root}'"
|
|
239
|
+
)
|
|
240
|
+
return resolved_path
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class LocalGitWorkspace:
|
|
244
|
+
"""Subprocess-backed git workspace using tokenless remotes and isolated auth state."""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
*,
|
|
249
|
+
git_token: str | None = None,
|
|
250
|
+
git_token_provider: Callable[[], str | None] | None = None,
|
|
251
|
+
command_timeout_seconds: int = 30,
|
|
252
|
+
output_limit_bytes: int = 4096,
|
|
253
|
+
allow_local_file_remotes: bool = False,
|
|
254
|
+
) -> None:
|
|
255
|
+
if command_timeout_seconds <= 0:
|
|
256
|
+
raise ValueError("command_timeout_seconds must be positive")
|
|
257
|
+
if output_limit_bytes <= 0:
|
|
258
|
+
raise ValueError("output_limit_bytes must be positive")
|
|
259
|
+
if git_token is not None and git_token_provider is not None:
|
|
260
|
+
raise ValueError("Provide either git_token or git_token_provider, not both")
|
|
261
|
+
self._static_git_token = git_token
|
|
262
|
+
self._git_token_provider = git_token_provider
|
|
263
|
+
self._has_token_auth = git_token is not None or git_token_provider is not None
|
|
264
|
+
self._last_resolved_token: str | None = None
|
|
265
|
+
self._command_timeout_seconds = command_timeout_seconds
|
|
266
|
+
self._output_limit_bytes = output_limit_bytes
|
|
267
|
+
self._allow_local_file_remotes = allow_local_file_remotes
|
|
268
|
+
self._command_results: list[GitCommandResult] = []
|
|
269
|
+
self._workspaces: dict[Path, _LocalWorkspaceRecord] = {}
|
|
270
|
+
|
|
271
|
+
def _resolve_git_token(self) -> str | None:
|
|
272
|
+
"""Resolve the current git token, minting a fresh one per call when a provider is set."""
|
|
273
|
+
|
|
274
|
+
if self._git_token_provider is not None:
|
|
275
|
+
token = self._git_token_provider()
|
|
276
|
+
else:
|
|
277
|
+
token = self._static_git_token
|
|
278
|
+
self._last_resolved_token = token or None
|
|
279
|
+
return self._last_resolved_token
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def command_results(self) -> tuple[GitCommandResult, ...]:
|
|
283
|
+
return tuple(self._command_results)
|
|
284
|
+
|
|
285
|
+
def clone_repository(self, request: CloneWorkspaceRequest) -> CloneWorkspaceResult:
|
|
286
|
+
_validate_tokenless_remote_url(
|
|
287
|
+
request.remote_url,
|
|
288
|
+
repo_full_name=request.repo_full_name,
|
|
289
|
+
require_github_match=self._has_token_auth,
|
|
290
|
+
allow_local_file_remotes=self._allow_local_file_remotes,
|
|
291
|
+
)
|
|
292
|
+
workspace_path = resolve_workspace_path(request.workspace_root, request.workspace_path)
|
|
293
|
+
workspace_root = request.workspace_root.resolve()
|
|
294
|
+
workspace_root.mkdir(parents=True, exist_ok=True)
|
|
295
|
+
workspace_path.parent.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
record = _LocalWorkspaceRecord(
|
|
297
|
+
repo_full_name=request.repo_full_name,
|
|
298
|
+
remote_url=request.remote_url,
|
|
299
|
+
workspace_root=workspace_root,
|
|
300
|
+
workspace_path=workspace_path,
|
|
301
|
+
)
|
|
302
|
+
if workspace_path.exists():
|
|
303
|
+
record = self._rehydrate_existing_clone(record)
|
|
304
|
+
return CloneWorkspaceResult(
|
|
305
|
+
repo_full_name=record.repo_full_name,
|
|
306
|
+
remote_url=record.remote_url,
|
|
307
|
+
workspace_root=record.workspace_root,
|
|
308
|
+
workspace_path=record.workspace_path,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
self._run_git(
|
|
312
|
+
["clone", "--no-checkout", request.remote_url, str(workspace_path)],
|
|
313
|
+
cwd=None,
|
|
314
|
+
record=record,
|
|
315
|
+
)
|
|
316
|
+
self._workspaces[workspace_path] = record
|
|
317
|
+
return CloneWorkspaceResult(
|
|
318
|
+
repo_full_name=request.repo_full_name,
|
|
319
|
+
remote_url=request.remote_url,
|
|
320
|
+
workspace_root=workspace_root,
|
|
321
|
+
workspace_path=workspace_path,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _rehydrate_existing_clone(self, record: _LocalWorkspaceRecord) -> _LocalWorkspaceRecord:
|
|
325
|
+
try:
|
|
326
|
+
is_work_tree = self._run_git(
|
|
327
|
+
["rev-parse", "--is-inside-work-tree"],
|
|
328
|
+
cwd=record.workspace_path,
|
|
329
|
+
record=record,
|
|
330
|
+
).stdout.strip()
|
|
331
|
+
top_level = self._run_git(
|
|
332
|
+
["rev-parse", "--show-toplevel"],
|
|
333
|
+
cwd=record.workspace_path,
|
|
334
|
+
record=record,
|
|
335
|
+
).stdout.strip()
|
|
336
|
+
origin_url = self._run_git(
|
|
337
|
+
["config", "--get", "remote.origin.url"],
|
|
338
|
+
cwd=record.workspace_path,
|
|
339
|
+
record=record,
|
|
340
|
+
).stdout.strip()
|
|
341
|
+
except GitWorkspaceCommandError as error:
|
|
342
|
+
raise GitWorkspacePolicyError(
|
|
343
|
+
f"Existing clone workspace '{record.workspace_path}' is not a git work tree"
|
|
344
|
+
) from error
|
|
345
|
+
|
|
346
|
+
if is_work_tree != "true" or Path(top_level).resolve() != record.workspace_path:
|
|
347
|
+
raise GitWorkspacePolicyError(
|
|
348
|
+
f"Existing clone workspace '{record.workspace_path}' is not a git work tree root"
|
|
349
|
+
)
|
|
350
|
+
if origin_url != record.remote_url:
|
|
351
|
+
raise GitWorkspacePolicyError(
|
|
352
|
+
"Existing clone workspace origin does not match expected origin"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
self._workspaces[record.workspace_path] = record
|
|
356
|
+
return record
|
|
357
|
+
|
|
358
|
+
def adopt_existing_clone(self, request: AdoptWorkspaceRequest) -> CloneWorkspaceResult:
|
|
359
|
+
work_branch = validate_work_branch(request.work_branch)
|
|
360
|
+
record = self._rehydrate_existing_clone(
|
|
361
|
+
_LocalWorkspaceRecord(
|
|
362
|
+
repo_full_name=request.repo_full_name,
|
|
363
|
+
remote_url=request.remote_url,
|
|
364
|
+
workspace_root=request.workspace_root.resolve(),
|
|
365
|
+
workspace_path=resolve_workspace_path(
|
|
366
|
+
request.workspace_root, request.workspace_path
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
self._require_current_branch(record, work_branch, operation="adopt")
|
|
371
|
+
return CloneWorkspaceResult(
|
|
372
|
+
repo_full_name=record.repo_full_name,
|
|
373
|
+
remote_url=record.remote_url,
|
|
374
|
+
workspace_root=record.workspace_root,
|
|
375
|
+
workspace_path=record.workspace_path,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def fetch_base_branch(self, request: FetchBranchRequest) -> FetchBranchResult:
|
|
379
|
+
base_branch = validate_base_branch(request.base_branch)
|
|
380
|
+
record = self._get_workspace(request.workspace_path)
|
|
381
|
+
self._require_same_repo(record, request.repo_full_name)
|
|
382
|
+
self._run_git(
|
|
383
|
+
["fetch", "origin", f"{base_branch}:refs/remotes/origin/{base_branch}"],
|
|
384
|
+
cwd=record.workspace_path,
|
|
385
|
+
record=record,
|
|
386
|
+
)
|
|
387
|
+
return FetchBranchResult(
|
|
388
|
+
repo_full_name=request.repo_full_name,
|
|
389
|
+
workspace_path=record.workspace_path,
|
|
390
|
+
base_branch=base_branch,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def checkout_work_branch(
|
|
394
|
+
self,
|
|
395
|
+
request: CheckoutWorkBranchRequest,
|
|
396
|
+
) -> CheckoutWorkBranchResult:
|
|
397
|
+
base_branch = validate_base_branch(request.base_branch)
|
|
398
|
+
work_branch = validate_work_branch(request.work_branch)
|
|
399
|
+
record = self._get_workspace(request.workspace_path)
|
|
400
|
+
self._require_same_repo(record, request.repo_full_name)
|
|
401
|
+
checkout_ref = f"refs/remotes/origin/{base_branch}"
|
|
402
|
+
if self._remote_work_branch_exists(record, work_branch):
|
|
403
|
+
checkout_ref = f"refs/remotes/origin/{work_branch}"
|
|
404
|
+
self._run_git(
|
|
405
|
+
["fetch", "origin", f"{work_branch}:{checkout_ref}"],
|
|
406
|
+
cwd=record.workspace_path,
|
|
407
|
+
record=record,
|
|
408
|
+
)
|
|
409
|
+
self._run_git(
|
|
410
|
+
["checkout", "-B", work_branch, checkout_ref],
|
|
411
|
+
cwd=record.workspace_path,
|
|
412
|
+
record=record,
|
|
413
|
+
)
|
|
414
|
+
return CheckoutWorkBranchResult(
|
|
415
|
+
repo_full_name=request.repo_full_name,
|
|
416
|
+
workspace_path=record.workspace_path,
|
|
417
|
+
base_branch=base_branch,
|
|
418
|
+
work_branch=work_branch,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _remote_work_branch_exists(
|
|
422
|
+
self,
|
|
423
|
+
record: _LocalWorkspaceRecord,
|
|
424
|
+
work_branch: str,
|
|
425
|
+
) -> bool:
|
|
426
|
+
try:
|
|
427
|
+
self._run_git(
|
|
428
|
+
["ls-remote", "--exit-code", "origin", f"refs/heads/{work_branch}"],
|
|
429
|
+
cwd=record.workspace_path,
|
|
430
|
+
record=record,
|
|
431
|
+
)
|
|
432
|
+
except GitWorkspaceCommandError:
|
|
433
|
+
latest_result = self._command_results[-1]
|
|
434
|
+
if latest_result.returncode == 2:
|
|
435
|
+
return False
|
|
436
|
+
raise
|
|
437
|
+
return True
|
|
438
|
+
|
|
439
|
+
def commit_all(self, request: CommitAllRequest) -> CommitAllResult:
|
|
440
|
+
work_branch = validate_work_branch(request.work_branch)
|
|
441
|
+
record = self._get_workspace(request.workspace_path)
|
|
442
|
+
self._require_same_repo(record, request.repo_full_name)
|
|
443
|
+
self._require_current_branch(record, work_branch, operation="commit")
|
|
444
|
+
|
|
445
|
+
self._run_git(["add", "--all"], cwd=record.workspace_path, record=record)
|
|
446
|
+
status = self._run_git(["status", "--porcelain"], cwd=record.workspace_path, record=record)
|
|
447
|
+
if status.stdout == "":
|
|
448
|
+
commit_id = self._run_git(
|
|
449
|
+
["rev-parse", "HEAD"],
|
|
450
|
+
cwd=record.workspace_path,
|
|
451
|
+
record=record,
|
|
452
|
+
).stdout.strip()
|
|
453
|
+
return CommitAllResult(
|
|
454
|
+
repo_full_name=request.repo_full_name,
|
|
455
|
+
workspace_path=record.workspace_path,
|
|
456
|
+
work_branch=work_branch,
|
|
457
|
+
commit_message=request.commit_message,
|
|
458
|
+
author_name=request.author_name,
|
|
459
|
+
author_email=request.author_email,
|
|
460
|
+
commit_id=commit_id,
|
|
461
|
+
)
|
|
462
|
+
self._run_git(
|
|
463
|
+
[
|
|
464
|
+
"-c",
|
|
465
|
+
f"user.name={request.author_name}",
|
|
466
|
+
"-c",
|
|
467
|
+
f"user.email={request.author_email}",
|
|
468
|
+
"commit",
|
|
469
|
+
"-m",
|
|
470
|
+
request.commit_message,
|
|
471
|
+
"--author",
|
|
472
|
+
f"{request.author_name} <{request.author_email}>",
|
|
473
|
+
],
|
|
474
|
+
cwd=record.workspace_path,
|
|
475
|
+
record=record,
|
|
476
|
+
)
|
|
477
|
+
commit_id = self._run_git(
|
|
478
|
+
["rev-parse", "HEAD"],
|
|
479
|
+
cwd=record.workspace_path,
|
|
480
|
+
record=record,
|
|
481
|
+
).stdout.strip()
|
|
482
|
+
return CommitAllResult(
|
|
483
|
+
repo_full_name=request.repo_full_name,
|
|
484
|
+
workspace_path=record.workspace_path,
|
|
485
|
+
work_branch=work_branch,
|
|
486
|
+
commit_message=request.commit_message,
|
|
487
|
+
author_name=request.author_name,
|
|
488
|
+
author_email=request.author_email,
|
|
489
|
+
commit_id=commit_id,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
def push_branch(self, request: PushBranchRequest) -> PushBranchResult:
|
|
493
|
+
validate_push_branch(
|
|
494
|
+
work_branch=request.work_branch,
|
|
495
|
+
base_branch=request.base_branch,
|
|
496
|
+
protected_branches=request.protected_branches,
|
|
497
|
+
)
|
|
498
|
+
record = self._get_workspace(request.workspace_path)
|
|
499
|
+
self._require_same_repo(record, request.repo_full_name)
|
|
500
|
+
self._require_current_branch(record, request.work_branch, operation="push")
|
|
501
|
+
commit_id = self._run_git(
|
|
502
|
+
["rev-parse", "HEAD"],
|
|
503
|
+
cwd=record.workspace_path,
|
|
504
|
+
record=record,
|
|
505
|
+
).stdout.strip()
|
|
506
|
+
refspec = build_push_refspec(request.work_branch)
|
|
507
|
+
self._run_git(["push", "origin", refspec], cwd=record.workspace_path, record=record)
|
|
508
|
+
return PushBranchResult(
|
|
509
|
+
repo_full_name=request.repo_full_name,
|
|
510
|
+
workspace_path=record.workspace_path,
|
|
511
|
+
work_branch=request.work_branch,
|
|
512
|
+
refspec=refspec,
|
|
513
|
+
commit_id=commit_id,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
def prepare_verifier_workspace(
|
|
517
|
+
self,
|
|
518
|
+
request: PrepareVerifierWorkspaceRequest,
|
|
519
|
+
) -> PrepareVerifierWorkspaceResult:
|
|
520
|
+
work_branch = validate_work_branch(request.work_branch)
|
|
521
|
+
approved_head_sha = validate_commit_sha(request.approved_head_sha)
|
|
522
|
+
record = self._get_or_rehydrate_verifier_workspace(
|
|
523
|
+
workspace_root=request.workspace_root,
|
|
524
|
+
workspace_path=request.workspace_path,
|
|
525
|
+
repo_full_name=request.repo_full_name,
|
|
526
|
+
)
|
|
527
|
+
self._require_same_repo(record, request.repo_full_name)
|
|
528
|
+
self._require_clean_verifier_workspace(record)
|
|
529
|
+
|
|
530
|
+
remote_ref = f"refs/remotes/origin/{work_branch}"
|
|
531
|
+
self._run_git(
|
|
532
|
+
["fetch", "origin", f"{work_branch}:{remote_ref}"],
|
|
533
|
+
cwd=record.workspace_path,
|
|
534
|
+
record=record,
|
|
535
|
+
)
|
|
536
|
+
fetched_head_sha = (
|
|
537
|
+
self._run_git(
|
|
538
|
+
["rev-parse", remote_ref],
|
|
539
|
+
cwd=record.workspace_path,
|
|
540
|
+
record=record,
|
|
541
|
+
)
|
|
542
|
+
.stdout.strip()
|
|
543
|
+
.lower()
|
|
544
|
+
)
|
|
545
|
+
if fetched_head_sha != approved_head_sha:
|
|
546
|
+
raise GitWorkspacePolicyError(
|
|
547
|
+
f"Fetched work branch head '{fetched_head_sha}' does not match "
|
|
548
|
+
f"approved head '{approved_head_sha}'"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
local_head_sha = self._local_head_sha_or_none(record)
|
|
552
|
+
if local_head_sha is not None and local_head_sha != approved_head_sha:
|
|
553
|
+
raise GitWorkspacePolicyError(
|
|
554
|
+
f"Verifier workspace local HEAD '{local_head_sha}' does not match "
|
|
555
|
+
f"approved head '{approved_head_sha}' before verifier reset"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
self._run_git(
|
|
559
|
+
["checkout", "-B", work_branch, approved_head_sha],
|
|
560
|
+
cwd=record.workspace_path,
|
|
561
|
+
record=record,
|
|
562
|
+
)
|
|
563
|
+
self._run_git(
|
|
564
|
+
["reset", "--hard", approved_head_sha],
|
|
565
|
+
cwd=record.workspace_path,
|
|
566
|
+
record=record,
|
|
567
|
+
)
|
|
568
|
+
attested_head_sha = (
|
|
569
|
+
self._run_git(
|
|
570
|
+
["rev-parse", "HEAD"],
|
|
571
|
+
cwd=record.workspace_path,
|
|
572
|
+
record=record,
|
|
573
|
+
)
|
|
574
|
+
.stdout.strip()
|
|
575
|
+
.lower()
|
|
576
|
+
)
|
|
577
|
+
if attested_head_sha != approved_head_sha:
|
|
578
|
+
raise GitWorkspacePolicyError(
|
|
579
|
+
f"Verifier workspace HEAD '{attested_head_sha}' does not match "
|
|
580
|
+
f"approved head '{approved_head_sha}'"
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
status_summary = self._require_clean_verifier_workspace(record)
|
|
584
|
+
|
|
585
|
+
return PrepareVerifierWorkspaceResult(
|
|
586
|
+
repo_full_name=request.repo_full_name,
|
|
587
|
+
workspace_root=record.workspace_root,
|
|
588
|
+
workspace_path=record.workspace_path,
|
|
589
|
+
work_branch=work_branch,
|
|
590
|
+
approved_head_sha=approved_head_sha,
|
|
591
|
+
attested_head_sha=attested_head_sha,
|
|
592
|
+
status_summary=status_summary,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def collect_git_evidence(
|
|
596
|
+
self,
|
|
597
|
+
workspace_path: Path,
|
|
598
|
+
output_limit_bytes: int | None = None,
|
|
599
|
+
) -> GitEvidence:
|
|
600
|
+
from agentic_runner.integrations.git.evidence import collect_git_evidence
|
|
601
|
+
|
|
602
|
+
record = self._get_workspace(workspace_path)
|
|
603
|
+
return collect_git_evidence(
|
|
604
|
+
workspace_path=record.workspace_path,
|
|
605
|
+
workspace_root=record.workspace_root,
|
|
606
|
+
output_limit_bytes=output_limit_bytes or self._output_limit_bytes,
|
|
607
|
+
command_timeout_seconds=self._command_timeout_seconds,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
def _get_workspace(self, workspace_path: Path) -> _LocalWorkspaceRecord:
|
|
611
|
+
resolved_path = workspace_path.resolve()
|
|
612
|
+
try:
|
|
613
|
+
record = self._workspaces[resolved_path]
|
|
614
|
+
except KeyError as error:
|
|
615
|
+
raise GitWorkspacePolicyError(
|
|
616
|
+
f"Git workspace '{resolved_path}' was not cloned"
|
|
617
|
+
) from error
|
|
618
|
+
resolve_workspace_path(record.workspace_root, workspace_path)
|
|
619
|
+
return record
|
|
620
|
+
|
|
621
|
+
def _get_or_rehydrate_verifier_workspace(
|
|
622
|
+
self,
|
|
623
|
+
*,
|
|
624
|
+
workspace_root: Path,
|
|
625
|
+
workspace_path: Path,
|
|
626
|
+
repo_full_name: str,
|
|
627
|
+
) -> _LocalWorkspaceRecord:
|
|
628
|
+
resolved_path = resolve_workspace_path(workspace_root, workspace_path)
|
|
629
|
+
try:
|
|
630
|
+
record = self._workspaces[resolved_path]
|
|
631
|
+
except KeyError:
|
|
632
|
+
record = self._rehydrate_verifier_workspace(
|
|
633
|
+
repo_full_name=repo_full_name,
|
|
634
|
+
workspace_root=workspace_root,
|
|
635
|
+
workspace_path=resolved_path,
|
|
636
|
+
)
|
|
637
|
+
else:
|
|
638
|
+
resolve_workspace_path(record.workspace_root, workspace_path)
|
|
639
|
+
return record
|
|
640
|
+
|
|
641
|
+
def _rehydrate_verifier_workspace(
|
|
642
|
+
self,
|
|
643
|
+
*,
|
|
644
|
+
repo_full_name: str,
|
|
645
|
+
workspace_root: Path,
|
|
646
|
+
workspace_path: Path,
|
|
647
|
+
) -> _LocalWorkspaceRecord:
|
|
648
|
+
expected_remote_url = _expected_github_remote_url(repo_full_name)
|
|
649
|
+
record = _LocalWorkspaceRecord(
|
|
650
|
+
repo_full_name=repo_full_name,
|
|
651
|
+
remote_url=expected_remote_url,
|
|
652
|
+
workspace_root=workspace_root.resolve(),
|
|
653
|
+
workspace_path=workspace_path,
|
|
654
|
+
)
|
|
655
|
+
if not record.workspace_path.exists():
|
|
656
|
+
raise GitWorkspacePolicyError(
|
|
657
|
+
f"Existing verifier workspace '{record.workspace_path}' does not exist"
|
|
658
|
+
)
|
|
659
|
+
try:
|
|
660
|
+
is_work_tree = self._run_git(
|
|
661
|
+
["rev-parse", "--is-inside-work-tree"],
|
|
662
|
+
cwd=record.workspace_path,
|
|
663
|
+
record=record,
|
|
664
|
+
).stdout.strip()
|
|
665
|
+
top_level = self._run_git(
|
|
666
|
+
["rev-parse", "--show-toplevel"],
|
|
667
|
+
cwd=record.workspace_path,
|
|
668
|
+
record=record,
|
|
669
|
+
).stdout.strip()
|
|
670
|
+
origin_url = self._run_git(
|
|
671
|
+
["config", "--get", "remote.origin.url"],
|
|
672
|
+
cwd=record.workspace_path,
|
|
673
|
+
record=record,
|
|
674
|
+
).stdout.strip()
|
|
675
|
+
except GitWorkspaceCommandError as error:
|
|
676
|
+
raise GitWorkspacePolicyError(
|
|
677
|
+
f"Existing verifier workspace '{record.workspace_path}' is not a git work tree"
|
|
678
|
+
) from error
|
|
679
|
+
|
|
680
|
+
if is_work_tree != "true" or Path(top_level).resolve() != record.workspace_path:
|
|
681
|
+
raise GitWorkspacePolicyError(
|
|
682
|
+
f"Existing verifier workspace '{record.workspace_path}' is not a git work tree root"
|
|
683
|
+
)
|
|
684
|
+
if origin_url != expected_remote_url:
|
|
685
|
+
raise GitWorkspacePolicyError(
|
|
686
|
+
f"Existing verifier workspace origin '{origin_url}' does not match expected "
|
|
687
|
+
f"origin '{expected_remote_url}'"
|
|
688
|
+
)
|
|
689
|
+
_validate_tokenless_remote_url(
|
|
690
|
+
origin_url,
|
|
691
|
+
repo_full_name=repo_full_name,
|
|
692
|
+
require_github_match=True,
|
|
693
|
+
allow_local_file_remotes=False,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
self._workspaces[record.workspace_path] = record
|
|
697
|
+
return record
|
|
698
|
+
|
|
699
|
+
def _require_same_repo(self, record: _LocalWorkspaceRecord, repo_full_name: str) -> None:
|
|
700
|
+
if record.repo_full_name != repo_full_name:
|
|
701
|
+
raise GitWorkspacePolicyError(
|
|
702
|
+
f"Workspace repo '{record.repo_full_name}' does not match request repo "
|
|
703
|
+
f"'{repo_full_name}'"
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _require_current_branch(
|
|
707
|
+
self,
|
|
708
|
+
record: _LocalWorkspaceRecord,
|
|
709
|
+
work_branch: str,
|
|
710
|
+
*,
|
|
711
|
+
operation: str,
|
|
712
|
+
) -> None:
|
|
713
|
+
current_branch = self._run_git(
|
|
714
|
+
["branch", "--show-current"],
|
|
715
|
+
cwd=record.workspace_path,
|
|
716
|
+
record=record,
|
|
717
|
+
).stdout.strip()
|
|
718
|
+
if current_branch != work_branch:
|
|
719
|
+
raise GitWorkspacePolicyError(
|
|
720
|
+
f"Cannot {operation} branch '{work_branch}' while current branch is "
|
|
721
|
+
f"'{current_branch}'"
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
def _require_clean_verifier_workspace(self, record: _LocalWorkspaceRecord) -> str:
|
|
725
|
+
status_summary = self._run_git(
|
|
726
|
+
["status", "--short"],
|
|
727
|
+
cwd=record.workspace_path,
|
|
728
|
+
record=record,
|
|
729
|
+
).stdout
|
|
730
|
+
if status_summary:
|
|
731
|
+
raise GitWorkspacePolicyError(
|
|
732
|
+
"Verifier workspace is dirty immediately before verifier execution: "
|
|
733
|
+
f"{status_summary!r}"
|
|
734
|
+
)
|
|
735
|
+
return status_summary
|
|
736
|
+
|
|
737
|
+
def _local_head_sha_or_none(self, record: _LocalWorkspaceRecord) -> str | None:
|
|
738
|
+
try:
|
|
739
|
+
return (
|
|
740
|
+
self._run_git(
|
|
741
|
+
["rev-parse", "--verify", "HEAD"],
|
|
742
|
+
cwd=record.workspace_path,
|
|
743
|
+
record=record,
|
|
744
|
+
)
|
|
745
|
+
.stdout.strip()
|
|
746
|
+
.lower()
|
|
747
|
+
)
|
|
748
|
+
except GitWorkspaceCommandError:
|
|
749
|
+
return None
|
|
750
|
+
|
|
751
|
+
def _run_git(
|
|
752
|
+
self,
|
|
753
|
+
args: list[str],
|
|
754
|
+
*,
|
|
755
|
+
cwd: Path | None,
|
|
756
|
+
record: _LocalWorkspaceRecord,
|
|
757
|
+
) -> GitCommandResult:
|
|
758
|
+
# Two settings guard the same seam: the Runner runs git as its own uid over a
|
|
759
|
+
# worktree owned by a Contract's uid (ADR-0015 §1 — the Runner is the only thing
|
|
760
|
+
# that may touch the git credential, so the clone/fetch/commit/push half stays
|
|
761
|
+
# here while the Directive runs as the Contract).
|
|
762
|
+
#
|
|
763
|
+
# `safe.directory` names this one workspace and never `*`. Without it git refuses
|
|
764
|
+
# every command in that tree with "detected dubious ownership"; with `*` it would
|
|
765
|
+
# also trust every *other* repository it meets, including one the Contract made.
|
|
766
|
+
# It also suppresses the one check that would notice the Contract swapping the
|
|
767
|
+
# whole `.git` out from under the Runner (it owns the directory that contains
|
|
768
|
+
# it), so `require_runner_owned_git_dir` re-makes that check below before every
|
|
769
|
+
# call into a Workspace: what git reads as commands there (hooks,
|
|
770
|
+
# `core.fsmonitor`, `filter.*.clean`) has to be the Runner's own.
|
|
771
|
+
#
|
|
772
|
+
# `diff.ignoreSubmodules=dirty` covers the way back in. A Directive can `git init`
|
|
773
|
+
# a directory inside its checkout, and once `git add --all` records that as a
|
|
774
|
+
# gitlink the Runner's next `status`/`diff` descends into it to ask whether it is
|
|
775
|
+
# dirty — which runs a git *inside* it, reading the config the Contract owns:
|
|
776
|
+
# `core.fsmonitor` and `filter.*.clean` there both execute as the Runner. The
|
|
777
|
+
# ownership check does not fire on that path (a submodule is opened from the
|
|
778
|
+
# superproject, not discovered from a directory), so the descent itself is what
|
|
779
|
+
# has to stop; `git status` honours this through `git_diff_ui_config`. `dirty`
|
|
780
|
+
# rather than `all`: it drops exactly the worktree question that needs the
|
|
781
|
+
# descent, while a gitlink still shows up and still commits — with `all`, `git
|
|
782
|
+
# commit` reports "nothing to commit" for a gitlink-only change and exits 1.
|
|
783
|
+
# Nothing real is lost: the Runner clones `--no-checkout` and never initialises a
|
|
784
|
+
# submodule, so it has no submodule worktree to report on.
|
|
785
|
+
argv = [
|
|
786
|
+
"git",
|
|
787
|
+
"-c",
|
|
788
|
+
"credential.helper=",
|
|
789
|
+
"-c",
|
|
790
|
+
f"safe.directory={record.workspace_path}",
|
|
791
|
+
"-c",
|
|
792
|
+
"diff.ignoreSubmodules=dirty",
|
|
793
|
+
*args,
|
|
794
|
+
]
|
|
795
|
+
if cwd is not None:
|
|
796
|
+
require_runner_owned_git_dir(record.workspace_path)
|
|
797
|
+
env = self._git_env(record.workspace_root)
|
|
798
|
+
try:
|
|
799
|
+
completed = subprocess.run(
|
|
800
|
+
argv,
|
|
801
|
+
cwd=cwd,
|
|
802
|
+
env=env,
|
|
803
|
+
shell=False,
|
|
804
|
+
capture_output=True,
|
|
805
|
+
text=True,
|
|
806
|
+
timeout=self._command_timeout_seconds,
|
|
807
|
+
check=False,
|
|
808
|
+
)
|
|
809
|
+
except subprocess.TimeoutExpired as error:
|
|
810
|
+
result = GitCommandResult(
|
|
811
|
+
argv=tuple(argv),
|
|
812
|
+
cwd=cwd,
|
|
813
|
+
returncode=-1,
|
|
814
|
+
stdout=self._sanitize_output(error.stdout),
|
|
815
|
+
stderr=self._sanitize_output(error.stderr),
|
|
816
|
+
timed_out=True,
|
|
817
|
+
)
|
|
818
|
+
self._command_results.append(result)
|
|
819
|
+
raise GitWorkspaceCommandError(
|
|
820
|
+
f"Git command timed out after {self._command_timeout_seconds}s: "
|
|
821
|
+
f"{_format_safe_argv(argv)}"
|
|
822
|
+
) from None
|
|
823
|
+
|
|
824
|
+
result = GitCommandResult(
|
|
825
|
+
argv=tuple(argv),
|
|
826
|
+
cwd=cwd,
|
|
827
|
+
returncode=completed.returncode,
|
|
828
|
+
stdout=self._sanitize_output(completed.stdout),
|
|
829
|
+
stderr=self._sanitize_output(completed.stderr),
|
|
830
|
+
)
|
|
831
|
+
self._command_results.append(result)
|
|
832
|
+
if completed.returncode != 0:
|
|
833
|
+
raise GitWorkspaceCommandError(
|
|
834
|
+
f"Git command failed with exit code {completed.returncode}: "
|
|
835
|
+
f"{_format_safe_argv(argv)}; stderr={result.stderr!r}"
|
|
836
|
+
) from None
|
|
837
|
+
return result
|
|
838
|
+
|
|
839
|
+
def _git_env(self, workspace_root: Path) -> dict[str, str]:
|
|
840
|
+
home = workspace_root / ".agentic-os-git-home"
|
|
841
|
+
home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
842
|
+
askpass_path = home / "askpass.sh"
|
|
843
|
+
if not askpass_path.exists() or askpass_path.read_text(encoding="utf-8") != _ASKPASS_SCRIPT:
|
|
844
|
+
askpass_path.write_text(_ASKPASS_SCRIPT, encoding="utf-8")
|
|
845
|
+
askpass_path.chmod(0o700)
|
|
846
|
+
|
|
847
|
+
env = _base_git_subprocess_env()
|
|
848
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
849
|
+
env["GIT_CONFIG_NOSYSTEM"] = "1"
|
|
850
|
+
env["HOME"] = str(home)
|
|
851
|
+
env["GIT_ASKPASS"] = str(askpass_path)
|
|
852
|
+
token = self._resolve_git_token()
|
|
853
|
+
if token:
|
|
854
|
+
env[_TOKEN_ENV_KEY] = token
|
|
855
|
+
return env
|
|
856
|
+
|
|
857
|
+
def _sanitize_output(self, output: str | bytes | None) -> str:
|
|
858
|
+
if output is None:
|
|
859
|
+
return ""
|
|
860
|
+
text = output.decode(errors="replace") if isinstance(output, bytes) else output
|
|
861
|
+
token = self._last_resolved_token
|
|
862
|
+
if token:
|
|
863
|
+
text = text.replace(token, "[REDACTED]")
|
|
864
|
+
return text[: self._output_limit_bytes]
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def _base_git_subprocess_env() -> dict[str, str]:
|
|
868
|
+
env: dict[str, str] = {}
|
|
869
|
+
for key in ("PATH", "LANG", "LC_ALL", "LC_CTYPE"):
|
|
870
|
+
value = os.environ.get(key)
|
|
871
|
+
if value is not None:
|
|
872
|
+
env[key] = value
|
|
873
|
+
return env
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _validate_tokenless_remote_url(
|
|
877
|
+
remote_url: str,
|
|
878
|
+
*,
|
|
879
|
+
repo_full_name: str,
|
|
880
|
+
require_github_match: bool,
|
|
881
|
+
allow_local_file_remotes: bool,
|
|
882
|
+
) -> None:
|
|
883
|
+
if Path(remote_url).is_absolute():
|
|
884
|
+
if allow_local_file_remotes:
|
|
885
|
+
return
|
|
886
|
+
raise GitWorkspacePolicyError(
|
|
887
|
+
"Local absolute remotes are only allowed when local file remotes are enabled"
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
parsed = urlsplit(remote_url)
|
|
891
|
+
if parsed.scheme != "https" or not parsed.netloc:
|
|
892
|
+
raise GitWorkspacePolicyError("Remote URL must be a tokenless HTTPS URL")
|
|
893
|
+
if parsed.username is not None or parsed.password is not None:
|
|
894
|
+
raise GitWorkspacePolicyError("Remote URL must be tokenless and must not contain userinfo")
|
|
895
|
+
if parsed.query or parsed.fragment:
|
|
896
|
+
raise GitWorkspacePolicyError("Remote URL must not contain query or fragment components")
|
|
897
|
+
if require_github_match:
|
|
898
|
+
expected_path = _validated_github_repo_path(repo_full_name)
|
|
899
|
+
if parsed.hostname != "github.com" or parsed.path != expected_path:
|
|
900
|
+
raise GitWorkspacePolicyError(
|
|
901
|
+
"Tokenized git operations require a github.com remote matching repo_full_name"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _validated_github_repo_path(repo_full_name: str) -> str:
|
|
906
|
+
parts = repo_full_name.split("/")
|
|
907
|
+
if len(parts) != 2:
|
|
908
|
+
raise GitWorkspacePolicyError("repo_full_name must be an owner/repo name")
|
|
909
|
+
if any(
|
|
910
|
+
part in {"", ".", ".."} or _REPO_PATH_SEGMENT_RE.fullmatch(part) is None for part in parts
|
|
911
|
+
):
|
|
912
|
+
raise GitWorkspacePolicyError("repo_full_name must contain safe owner/repo segments")
|
|
913
|
+
return f"/{parts[0]}/{parts[1]}.git"
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _expected_github_remote_url(repo_full_name: str) -> str:
|
|
917
|
+
return f"https://github.com{_validated_github_repo_path(repo_full_name)}"
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _format_safe_argv(argv: list[str]) -> str:
|
|
921
|
+
return " ".join(argv)
|