@agentsdock/server 1.0.7-beta.5
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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- package/server/workspace_git.py +545 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""On-demand, native-admin Git workspace operations; no background watchers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import difflib
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import signal
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from contextlib import contextmanager, suppress
|
|
18
|
+
from typing import Any, Callable, Literal
|
|
19
|
+
|
|
20
|
+
from fastapi import HTTPException, Query, Request
|
|
21
|
+
from pydantic import BaseModel, Field
|
|
22
|
+
|
|
23
|
+
MAX_OUTPUT = 2 * 1024 * 1024
|
|
24
|
+
MAX_TEXT = 2 * 1024 * 1024
|
|
25
|
+
DEADLINE_SECONDS = 30
|
|
26
|
+
_LOCKS = [threading.Lock() for _ in range(64)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fail(code: str, message: str, status: int = 409) -> None:
|
|
30
|
+
raise HTTPException(status_code=status, detail={"code": code, "message": message})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@contextmanager
|
|
34
|
+
def repository_lock(root: Path, deadline: float):
|
|
35
|
+
lock = _LOCKS[hash(str(root)) % len(_LOCKS)]
|
|
36
|
+
if not lock.acquire(timeout=max(0, deadline - time.monotonic())):
|
|
37
|
+
fail("git_busy", "Another workspace Git action is still running. Refresh and try again.", 409)
|
|
38
|
+
try:
|
|
39
|
+
yield
|
|
40
|
+
finally:
|
|
41
|
+
lock.release()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GitAction(BaseModel):
|
|
45
|
+
action: Literal["stage", "unstage", "commit", "resolve", "continue", "abort"]
|
|
46
|
+
expected_revision: str = Field(min_length=64, max_length=64)
|
|
47
|
+
paths: list[str] | None = Field(default=None, max_length=1000)
|
|
48
|
+
message: str | None = Field(default=None, max_length=65536)
|
|
49
|
+
path: str | None = Field(default=None, max_length=4096)
|
|
50
|
+
content: str | None = Field(default=None, max_length=MAX_TEXT)
|
|
51
|
+
confirmed: bool = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Repository:
|
|
55
|
+
def __init__(self, workspace: Path):
|
|
56
|
+
self.deadline = time.monotonic() + DEADLINE_SECONDS
|
|
57
|
+
self.root = workspace.resolve(strict=True)
|
|
58
|
+
self.index_override: Path | None = None
|
|
59
|
+
self.publish_index_on_error = False
|
|
60
|
+
top, code, _ = self.git("rev-parse", "--show-toplevel", check=False)
|
|
61
|
+
if code or not top.strip():
|
|
62
|
+
fail("workspace_not_git", "This chat's working directory is not inside a Git worktree.", 422)
|
|
63
|
+
self.root = Path(os.fsdecode(top.rstrip(b"\n"))).resolve(strict=True)
|
|
64
|
+
gitdir, _, _ = self.git("rev-parse", "--absolute-git-dir")
|
|
65
|
+
self.gitdir = Path(os.fsdecode(gitdir.rstrip(b"\n"))).resolve(strict=True)
|
|
66
|
+
index, _, _ = self.git("rev-parse", "--git-path", "index")
|
|
67
|
+
self.index = Path(os.fsdecode(index.rstrip(b"\n")))
|
|
68
|
+
if not self.index.is_absolute():
|
|
69
|
+
self.index = self.root / self.index
|
|
70
|
+
if self.index.is_symlink():
|
|
71
|
+
fail("git_unsafe_index", "Git index is a symlink; use the terminal for this repository.")
|
|
72
|
+
|
|
73
|
+
def git(self, *args: str, check: bool = True, input: bytes | None = None,
|
|
74
|
+
limit: int = MAX_OUTPUT, truncate: bool = False) -> tuple[bytes, int, bool]:
|
|
75
|
+
remaining = self.deadline - time.monotonic()
|
|
76
|
+
if remaining <= 0:
|
|
77
|
+
fail("git_timeout", "Git exceeded its 30 second deadline. Refresh before trying again.", 504)
|
|
78
|
+
env = {key: value for key, value in os.environ.items()
|
|
79
|
+
if not key.startswith("GIT_") or key in {"GIT_CONFIG_GLOBAL", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_NOSYSTEM"}}
|
|
80
|
+
env.update(GIT_TERMINAL_PROMPT="0", GIT_EDITOR="true", GIT_SEQUENCE_EDITOR="true",
|
|
81
|
+
GIT_OPTIONAL_LOCKS="0", GIT_NO_LAZY_FETCH="1", LC_ALL="C")
|
|
82
|
+
if self.index_override is not None:
|
|
83
|
+
env["GIT_INDEX_FILE"] = str(self.index_override)
|
|
84
|
+
command = ["git", "--literal-pathspecs", "-c", "core.fsmonitor=false",
|
|
85
|
+
"-c", "core.untrackedCache=false", "-c", "gc.auto=0",
|
|
86
|
+
"-c", "maintenance.auto=false", "-c", "credential.interactive=false",
|
|
87
|
+
"-c", "core.pager=cat", *args]
|
|
88
|
+
try:
|
|
89
|
+
with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors:
|
|
90
|
+
process = subprocess.Popen(command, cwd=self.root, env=env,
|
|
91
|
+
stdin=subprocess.PIPE if input is not None else subprocess.DEVNULL,
|
|
92
|
+
stdout=output, stderr=errors, start_new_session=True)
|
|
93
|
+
try:
|
|
94
|
+
process.communicate(input, timeout=remaining)
|
|
95
|
+
except subprocess.TimeoutExpired:
|
|
96
|
+
with suppress(ProcessLookupError):
|
|
97
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
98
|
+
process.wait()
|
|
99
|
+
fail("git_timeout", "Git timed out. Refresh repository status before trying again.", 504)
|
|
100
|
+
output.seek(0)
|
|
101
|
+
result = output.read(limit + 1)
|
|
102
|
+
errors.seek(0)
|
|
103
|
+
error = errors.read(8192).decode("utf-8", "replace").strip()
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
fail("git_unavailable", "Git is not installed on this server.", 501)
|
|
106
|
+
if check and process.returncode:
|
|
107
|
+
fail("git_command_failed", error or "Git could not complete this operation.")
|
|
108
|
+
clipped = len(result) > limit
|
|
109
|
+
if clipped and not truncate:
|
|
110
|
+
fail("git_result_too_large", "This repository result is too large for the workspace Git view.", 413)
|
|
111
|
+
return result[:limit], process.returncode, clipped
|
|
112
|
+
|
|
113
|
+
def path(self, value: str) -> str:
|
|
114
|
+
if (not isinstance(value, str) or not value or len(value) > 4096
|
|
115
|
+
or value.startswith("/") or "\\" in value or "\x00" in value
|
|
116
|
+
or any(part in ("", ".", "..") or part.casefold() == ".git" for part in value.split("/"))):
|
|
117
|
+
fail("git_invalid_path", "Select a repository-relative file outside Git's metadata directory.", 400)
|
|
118
|
+
try:
|
|
119
|
+
value.encode("utf-8")
|
|
120
|
+
except UnicodeEncodeError:
|
|
121
|
+
fail("git_filename_encoding", "This repository contains a filename that is not UTF-8. Use the terminal for this repository.", 415)
|
|
122
|
+
# Git pathspecs are literal; walk each parent without following links.
|
|
123
|
+
current = self.root
|
|
124
|
+
for part in value.split("/")[:-1]:
|
|
125
|
+
current /= part
|
|
126
|
+
try:
|
|
127
|
+
info = current.lstat()
|
|
128
|
+
except FileNotFoundError:
|
|
129
|
+
break # A deleted tracked path may have missing parent directories.
|
|
130
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
131
|
+
fail("git_unsafe_path", "Git paths cannot traverse symbolic links or non-directories.", 400)
|
|
132
|
+
return value
|
|
133
|
+
|
|
134
|
+
def read_worktree(self, path: str) -> bytes | None:
|
|
135
|
+
path = self.path(path)
|
|
136
|
+
parent = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
137
|
+
try:
|
|
138
|
+
for part in path.split("/")[:-1]:
|
|
139
|
+
next_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
|
|
140
|
+
os.close(parent)
|
|
141
|
+
parent = next_fd
|
|
142
|
+
name = path.split("/")[-1]
|
|
143
|
+
info = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
144
|
+
if stat.S_ISLNK(info.st_mode):
|
|
145
|
+
return os.fsencode(os.readlink(name, dir_fd=parent))
|
|
146
|
+
if not stat.S_ISREG(info.st_mode):
|
|
147
|
+
fail("git_not_regular_file", "This Git entry is not a regular text file.", 415)
|
|
148
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
|
149
|
+
try:
|
|
150
|
+
with os.fdopen(fd, "rb", closefd=False) as stream:
|
|
151
|
+
data = stream.read(MAX_TEXT + 1)
|
|
152
|
+
if len(data) > MAX_TEXT:
|
|
153
|
+
fail("git_file_too_large", "This file exceeds the 2 MiB Git text editor limit.", 413)
|
|
154
|
+
return data
|
|
155
|
+
finally:
|
|
156
|
+
os.close(fd)
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
return None
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
fail("git_unsafe_path", f"Cannot safely open this repository file: {exc.strerror}", 409)
|
|
161
|
+
finally:
|
|
162
|
+
os.close(parent)
|
|
163
|
+
|
|
164
|
+
def operation(self) -> str | None:
|
|
165
|
+
for marker, operation in (("rebase-merge", "rebase"), ("rebase-apply", "rebase"),
|
|
166
|
+
("MERGE_HEAD", "merge"), ("CHERRY_PICK_HEAD", "cherry-pick"),
|
|
167
|
+
("REVERT_HEAD", "revert")):
|
|
168
|
+
if (self.gitdir / marker).exists():
|
|
169
|
+
return operation
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
def status(self) -> dict[str, Any]:
|
|
173
|
+
raw, _, _ = self.git("status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none")
|
|
174
|
+
files: list[dict[str, Any]] = []
|
|
175
|
+
entries = iter(raw.split(b"\0"))
|
|
176
|
+
for entry in entries:
|
|
177
|
+
if not entry:
|
|
178
|
+
continue
|
|
179
|
+
xy = entry[:2].decode("ascii")
|
|
180
|
+
item: dict[str, Any] = {"path": os.fsdecode(entry[3:]), "index_status": xy[0],
|
|
181
|
+
"worktree_status": xy[1], "staged": xy[0] not in " ?",
|
|
182
|
+
"unstaged": xy[1] != " ", "untracked": xy == "??",
|
|
183
|
+
"conflicted": xy in {"DD", "AU", "UD", "UA", "DU", "AA", "UU"}}
|
|
184
|
+
if xy[0] in "RC" or xy[1] in "RC":
|
|
185
|
+
item["original_path"] = os.fsdecode(next(entries))
|
|
186
|
+
files.append(item)
|
|
187
|
+
head_raw, head_code, _ = self.git("rev-parse", "--verify", "HEAD", check=False)
|
|
188
|
+
branch_raw, _, _ = self.git("symbolic-ref", "--quiet", "--short", "HEAD", check=False)
|
|
189
|
+
head = None if head_code else head_raw.decode().strip()
|
|
190
|
+
branch = branch_raw.decode("utf-8", "replace").strip() or None
|
|
191
|
+
digest = hashlib.sha256()
|
|
192
|
+
digest.update(os.fsencode(self.root))
|
|
193
|
+
digest.update(os.fsencode(self.gitdir))
|
|
194
|
+
digest.update(raw)
|
|
195
|
+
digest.update(head_raw)
|
|
196
|
+
digest.update(branch_raw)
|
|
197
|
+
# Index bytes identify the exact staged tree, including conflict stages.
|
|
198
|
+
try:
|
|
199
|
+
digest.update(self.index.read_bytes())
|
|
200
|
+
except FileNotFoundError:
|
|
201
|
+
digest.update(b"unborn-index")
|
|
202
|
+
for item in files:
|
|
203
|
+
for path in (item["path"], item.get("original_path")):
|
|
204
|
+
if path is None:
|
|
205
|
+
continue
|
|
206
|
+
self.path(path)
|
|
207
|
+
try:
|
|
208
|
+
info = (self.root / path).lstat()
|
|
209
|
+
digest.update(str((info.st_dev, info.st_ino, info.st_mode, info.st_size,
|
|
210
|
+
info.st_mtime_ns, info.st_ctime_ns)).encode())
|
|
211
|
+
except FileNotFoundError:
|
|
212
|
+
digest.update(b"missing")
|
|
213
|
+
for marker in ("HEAD", "MERGE_HEAD", "MERGE_MSG", "CHERRY_PICK_HEAD", "REVERT_HEAD",
|
|
214
|
+
"ORIG_HEAD", "rebase-merge/head-name", "rebase-merge/onto",
|
|
215
|
+
"rebase-merge/git-rebase-todo", "rebase-merge/done",
|
|
216
|
+
"rebase-apply/next", "rebase-apply/last", "sequencer/todo"):
|
|
217
|
+
target = self.gitdir / marker
|
|
218
|
+
if target.is_file() and not target.is_symlink():
|
|
219
|
+
digest.update(marker.encode())
|
|
220
|
+
with target.open("rb") as stream:
|
|
221
|
+
digest.update(stream.read(MAX_OUTPUT))
|
|
222
|
+
return {"root": str(self.root), "branch": branch, "head": head,
|
|
223
|
+
"revision": digest.hexdigest(), "operation": self.operation(), "files": files,
|
|
224
|
+
"staged_count": sum(item["staged"] and not item["conflicted"] for item in files),
|
|
225
|
+
"conflict_count": sum(item["conflicted"] for item in files)}
|
|
226
|
+
|
|
227
|
+
def checked_status(self, revision: str) -> dict[str, Any]:
|
|
228
|
+
current = self.status()
|
|
229
|
+
if not re.fullmatch(r"[0-9a-f]{64}", revision or "") or current["revision"] != revision:
|
|
230
|
+
fail("git_stale_revision", "Repository changed since review. Refresh and review the current changes before trying again.")
|
|
231
|
+
return current
|
|
232
|
+
|
|
233
|
+
@staticmethod
|
|
234
|
+
def text(data: bytes | None) -> tuple[str | None, bool]:
|
|
235
|
+
if data is None:
|
|
236
|
+
return None, False
|
|
237
|
+
try:
|
|
238
|
+
if b"\0" in data:
|
|
239
|
+
return "", True
|
|
240
|
+
return data.decode("utf-8"), False
|
|
241
|
+
except UnicodeDecodeError:
|
|
242
|
+
return "", True
|
|
243
|
+
|
|
244
|
+
def diff(self, path: str, view: str) -> dict[str, Any]:
|
|
245
|
+
path = self.path(path)
|
|
246
|
+
if view not in {"staged", "unstaged"}:
|
|
247
|
+
fail("git_invalid_view", "Diff view must be staged or unstaged.", 400)
|
|
248
|
+
before = self.status()
|
|
249
|
+
item = next((entry for entry in before["files"] if entry["path"] == path), None)
|
|
250
|
+
if item is None:
|
|
251
|
+
fail("git_file_not_changed", "This file no longer has repository changes. Refresh the list.", 404)
|
|
252
|
+
binary = False
|
|
253
|
+
truncated = False
|
|
254
|
+
if item["untracked"] and view == "unstaged":
|
|
255
|
+
content, binary = self.text(self.read_worktree(path))
|
|
256
|
+
result = "" if binary else "".join(difflib.unified_diff([], (content or "").splitlines(keepends=True),
|
|
257
|
+
fromfile="/dev/null", tofile="b/" + path))
|
|
258
|
+
encoded = result.encode("utf-8")
|
|
259
|
+
truncated = len(encoded) > MAX_OUTPUT
|
|
260
|
+
result = encoded[:MAX_OUTPUT].decode("utf-8", "replace")
|
|
261
|
+
else:
|
|
262
|
+
args = ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--no-renames"]
|
|
263
|
+
if view == "staged":
|
|
264
|
+
args.append("--cached")
|
|
265
|
+
args += ["--", path]
|
|
266
|
+
if item.get("original_path"):
|
|
267
|
+
args.append(self.path(item["original_path"]))
|
|
268
|
+
output, _, truncated = self.git(*args, truncate=True)
|
|
269
|
+
result = output.decode("utf-8", "replace")
|
|
270
|
+
binary = b"Binary files " in output or b"GIT binary patch" in output
|
|
271
|
+
self.checked_status(before["revision"])
|
|
272
|
+
return {"path": path, "view": view, "diff": result, "binary": binary,
|
|
273
|
+
"truncated": truncated, "revision": before["revision"]}
|
|
274
|
+
|
|
275
|
+
def conflict(self, path: str) -> dict[str, Any]:
|
|
276
|
+
path = self.path(path)
|
|
277
|
+
before = self.status()
|
|
278
|
+
if not any(item["path"] == path and item["conflicted"] for item in before["files"]):
|
|
279
|
+
fail("git_not_conflicted", "This file no longer has unresolved conflicts. Refresh the list.")
|
|
280
|
+
raw, _, _ = self.git("ls-files", "--unmerged", "-z", "--", path)
|
|
281
|
+
result: dict[str, Any] = {"path": path, "base": None, "ours": None, "theirs": None,
|
|
282
|
+
"result": "", "binary": False, "revision": before["revision"]}
|
|
283
|
+
for entry in raw.split(b"\0"):
|
|
284
|
+
if not entry:
|
|
285
|
+
continue
|
|
286
|
+
mode, oid, stage = entry.split(b"\t", 1)[0].split(b" ")
|
|
287
|
+
data, _, _ = self.git("cat-file", "blob", oid.decode(), limit=MAX_TEXT)
|
|
288
|
+
value, binary = self.text(data)
|
|
289
|
+
result[{b"1": "base", b"2": "ours", b"3": "theirs"}[stage]] = value
|
|
290
|
+
result["binary"] |= binary or mode != b"100644" and mode != b"100755"
|
|
291
|
+
value, binary = self.text(self.read_worktree(path))
|
|
292
|
+
result["result"] = value or ""
|
|
293
|
+
result["binary"] |= binary
|
|
294
|
+
self.checked_status(before["revision"])
|
|
295
|
+
if len(json.dumps(result, ensure_ascii=False).encode("utf-8")) > 12 * 1024 * 1024:
|
|
296
|
+
fail("git_conflict_too_large", "This conflict is too large for the editor. Resolve it in the terminal.", 413)
|
|
297
|
+
return result
|
|
298
|
+
|
|
299
|
+
def safe_git_configuration(self, action: str, paths: list[str]) -> None:
|
|
300
|
+
# Ordinary hooks and filters may execute arbitrary code or alter reviewed
|
|
301
|
+
# blobs. Refuse these repositories instead of silently skipping policy.
|
|
302
|
+
if action in {"stage", "resolve", "continue", "abort"} and paths:
|
|
303
|
+
attributes, _, _ = self.git("check-attr", "-z", "filter", "merge", "--", *paths)
|
|
304
|
+
values = attributes.split(b"\0")
|
|
305
|
+
for position in range(0, len(values) - 2, 3):
|
|
306
|
+
attribute, value = values[position + 1:position + 3]
|
|
307
|
+
if value in {b"unspecified", b"unset"}:
|
|
308
|
+
continue
|
|
309
|
+
if attribute == b"merge" and (action in {"stage", "resolve"} or value in {b"set", b"text", b"binary", b"union"}):
|
|
310
|
+
continue
|
|
311
|
+
fail("git_custom_driver", "A selected file uses a custom Git filter or merge driver. Complete this operation in the terminal.")
|
|
312
|
+
if action in {"commit", "continue", "abort"}:
|
|
313
|
+
hookpath, _, _ = self.git("rev-parse", "--git-path", "hooks")
|
|
314
|
+
directory = Path(os.fsdecode(hookpath.rstrip(b"\n")))
|
|
315
|
+
if not directory.is_absolute():
|
|
316
|
+
directory = self.root / directory
|
|
317
|
+
if directory.exists() and any(item.is_file() and os.access(item, os.X_OK)
|
|
318
|
+
and not item.name.endswith(".sample") for item in directory.iterdir()):
|
|
319
|
+
fail("git_hooks_require_terminal", "This repository has executable Git hooks. Run this operation in the terminal so its hooks can run normally.")
|
|
320
|
+
if action == "continue" and self.operation() == "rebase":
|
|
321
|
+
todo = self.gitdir / "rebase-merge" / "git-rebase-todo"
|
|
322
|
+
if todo.is_file() and re.search(r"(?m)^\s*(exec|x)\s", todo.read_text()):
|
|
323
|
+
fail("git_rebase_exec", "This rebase has executable todo commands. Continue it in the terminal.")
|
|
324
|
+
|
|
325
|
+
@contextmanager
|
|
326
|
+
def index_transaction(self):
|
|
327
|
+
lock_path = Path(str(self.index) + ".lock")
|
|
328
|
+
try:
|
|
329
|
+
lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
330
|
+
except FileExistsError:
|
|
331
|
+
fail("git_index_locked", "Git's index is locked. Let an active operation finish; if a prior workspace action reported an index recovery error, restore its saved index before removing this lock.")
|
|
332
|
+
try:
|
|
333
|
+
temp_fd, name = tempfile.mkstemp(prefix="agentsdock-index-", dir=self.index.parent)
|
|
334
|
+
except OSError as exc:
|
|
335
|
+
os.close(lock_fd)
|
|
336
|
+
with suppress(FileNotFoundError):
|
|
337
|
+
lock_path.unlink()
|
|
338
|
+
fail("git_storage_failure", f"Could not prepare Git's index snapshot: {exc.strerror}. Check free space and permissions, then refresh.", 507)
|
|
339
|
+
temporary = Path(name)
|
|
340
|
+
preserve_recovery = False
|
|
341
|
+
def publish() -> None:
|
|
342
|
+
nonlocal preserve_recovery
|
|
343
|
+
if temporary.exists():
|
|
344
|
+
try:
|
|
345
|
+
completed_fd = os.open(temporary, os.O_RDONLY | os.O_NOFOLLOW)
|
|
346
|
+
try:
|
|
347
|
+
os.fsync(completed_fd)
|
|
348
|
+
finally:
|
|
349
|
+
os.close(completed_fd)
|
|
350
|
+
# Keep Git's real lock held while publishing the completed
|
|
351
|
+
# same-filesystem index. Do not copy it a second time: an
|
|
352
|
+
# ENOSPC here could otherwise lose the only correct index
|
|
353
|
+
# after commit/rebase has already changed HEAD or files.
|
|
354
|
+
os.replace(temporary, self.index)
|
|
355
|
+
except OSError as exc:
|
|
356
|
+
preserve_recovery = True
|
|
357
|
+
raise HTTPException(status_code=507, detail={
|
|
358
|
+
"code": "git_index_recovery_required",
|
|
359
|
+
"message": (
|
|
360
|
+
f"Git may have changed HEAD or working files, but its completed index could not be published: {exc.strerror}. "
|
|
361
|
+
f"The recovery index is retained at {temporary}; {lock_path} remains in place to block further Git writes. "
|
|
362
|
+
f"After fixing storage or permissions, restore that index to {self.index}, then remove the lock and refresh."
|
|
363
|
+
),
|
|
364
|
+
"recovery_index": str(temporary),
|
|
365
|
+
"index_path": str(self.index),
|
|
366
|
+
"lock_path": str(lock_path),
|
|
367
|
+
}) from exc
|
|
368
|
+
try:
|
|
369
|
+
with os.fdopen(temp_fd, "wb") as stream:
|
|
370
|
+
if self.index.exists():
|
|
371
|
+
stream.write(self.index.read_bytes())
|
|
372
|
+
if temporary.stat().st_size == 0:
|
|
373
|
+
temporary.unlink() # Git treats a missing index as an unborn index.
|
|
374
|
+
try:
|
|
375
|
+
yield temporary
|
|
376
|
+
except BaseException:
|
|
377
|
+
if self.publish_index_on_error:
|
|
378
|
+
publish()
|
|
379
|
+
raise
|
|
380
|
+
else:
|
|
381
|
+
publish()
|
|
382
|
+
finally:
|
|
383
|
+
self.index_override = None
|
|
384
|
+
os.close(lock_fd)
|
|
385
|
+
if not preserve_recovery:
|
|
386
|
+
with suppress(FileNotFoundError):
|
|
387
|
+
lock_path.unlink()
|
|
388
|
+
with suppress(FileNotFoundError):
|
|
389
|
+
temporary.unlink()
|
|
390
|
+
with suppress(FileNotFoundError):
|
|
391
|
+
Path(str(temporary) + ".lock").unlink()
|
|
392
|
+
|
|
393
|
+
def save_resolution(self, path: str, content: str, expected: bytes | None) -> None:
|
|
394
|
+
data = content.encode("utf-8")
|
|
395
|
+
if len(data) > MAX_TEXT or b"\0" in data:
|
|
396
|
+
fail("git_invalid_resolution", "Resolution must be UTF-8 text no larger than 2 MiB.", 400)
|
|
397
|
+
# Secure directory descriptors prevent a concurrent symlink parent swap
|
|
398
|
+
# from redirecting the save outside the canonical worktree.
|
|
399
|
+
parent = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
400
|
+
temp_name = ""
|
|
401
|
+
try:
|
|
402
|
+
for part in path.split("/")[:-1]:
|
|
403
|
+
next_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
|
|
404
|
+
os.close(parent)
|
|
405
|
+
parent = next_fd
|
|
406
|
+
name = path.split("/")[-1]
|
|
407
|
+
info = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
408
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
|
|
409
|
+
fail("git_unsafe_resolution", "Only regular files without hard links can be resolved in the editor.")
|
|
410
|
+
temp_name = ".agentsdock-resolution-" + os.urandom(12).hex()
|
|
411
|
+
fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
|
412
|
+
stat.S_IMODE(info.st_mode), dir_fd=parent)
|
|
413
|
+
os.fchmod(fd, stat.S_IMODE(info.st_mode))
|
|
414
|
+
with os.fdopen(fd, "wb") as stream:
|
|
415
|
+
stream.write(data)
|
|
416
|
+
stream.flush()
|
|
417
|
+
os.fsync(stream.fileno())
|
|
418
|
+
latest = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
419
|
+
if latest != info or self.read_worktree(path) != expected:
|
|
420
|
+
fail("git_stale_revision", "The conflict file changed while saving. Refresh before trying again.")
|
|
421
|
+
os.replace(temp_name, name, src_dir_fd=parent, dst_dir_fd=parent)
|
|
422
|
+
except FileNotFoundError:
|
|
423
|
+
fail("git_resolution_missing", "The conflict result file is missing. Restore it in the terminal before editing.")
|
|
424
|
+
finally:
|
|
425
|
+
if temp_name:
|
|
426
|
+
with suppress(FileNotFoundError):
|
|
427
|
+
os.unlink(temp_name, dir_fd=parent)
|
|
428
|
+
os.close(parent)
|
|
429
|
+
|
|
430
|
+
def action(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
431
|
+
action = request["action"]
|
|
432
|
+
with repository_lock(self.root, self.deadline):
|
|
433
|
+
with self.index_transaction() as temporary:
|
|
434
|
+
before = self.checked_status(request.get("expected_revision", ""))
|
|
435
|
+
paths = [self.path(value) for value in (request.get("paths") or [])]
|
|
436
|
+
available = {item["path"]: item for item in before["files"]}
|
|
437
|
+
driver_paths = paths
|
|
438
|
+
if action == "resolve":
|
|
439
|
+
driver_paths = [self.path(request.get("path") or "")]
|
|
440
|
+
elif action in {"continue", "abort"}:
|
|
441
|
+
tracked, _, _ = self.git("ls-files", "-z")
|
|
442
|
+
driver_paths = [os.fsdecode(value) for value in tracked.split(b"\0") if value]
|
|
443
|
+
self.safe_git_configuration(action, driver_paths)
|
|
444
|
+
if action in {"stage", "unstage"}:
|
|
445
|
+
if not paths or any(path not in available for path in paths):
|
|
446
|
+
fail("git_invalid_selection", "Select files from the current repository changes.", 400)
|
|
447
|
+
if any(available[path]["conflicted"] for path in paths):
|
|
448
|
+
fail("git_unresolved_conflict", "Resolve conflicted files in the conflict editor before staging.")
|
|
449
|
+
# Rename entries must operate on both index paths.
|
|
450
|
+
paths = list(dict.fromkeys(paths + [self.path(available[path]["original_path"])
|
|
451
|
+
for path in paths if available[path].get("original_path")]))
|
|
452
|
+
self.index_override = temporary
|
|
453
|
+
if action == "stage":
|
|
454
|
+
self.git("add", "--", *paths)
|
|
455
|
+
elif before["head"]:
|
|
456
|
+
self.git("reset", "--quiet", "HEAD", "--", *paths)
|
|
457
|
+
else:
|
|
458
|
+
self.git("update-index", "--force-remove", "--", *paths)
|
|
459
|
+
self.index_override = None
|
|
460
|
+
self.checked_status(before["revision"])
|
|
461
|
+
elif action == "commit":
|
|
462
|
+
message = request.get("message", "") or ""
|
|
463
|
+
if not message.strip():
|
|
464
|
+
fail("git_commit_message_required", "Enter a commit message.", 400)
|
|
465
|
+
if before["conflict_count"]:
|
|
466
|
+
fail("git_unresolved_conflict", "Resolve every conflict before committing.")
|
|
467
|
+
if before["operation"]:
|
|
468
|
+
fail("git_operation_in_progress", "Use Continue to finish the current Git operation.")
|
|
469
|
+
if not before["staged_count"]:
|
|
470
|
+
fail("git_nothing_staged", "Stage and review files before committing.")
|
|
471
|
+
self.index_override = temporary
|
|
472
|
+
self.publish_index_on_error = True
|
|
473
|
+
self.git("commit", "--file=-", input=message.encode("utf-8"))
|
|
474
|
+
elif action == "resolve":
|
|
475
|
+
path = self.path(request.get("path") or "")
|
|
476
|
+
if path not in available or not available[path]["conflicted"]:
|
|
477
|
+
fail("git_not_conflicted", "This file no longer has unresolved conflicts.")
|
|
478
|
+
conflict = self.conflict(path)
|
|
479
|
+
if conflict["binary"]:
|
|
480
|
+
fail("git_binary_conflict", "Resolve binary or symbolic-link conflicts in the terminal.", 415)
|
|
481
|
+
content = request.get("content")
|
|
482
|
+
if not isinstance(content, str):
|
|
483
|
+
fail("git_resolution_required", "Provide the resolved file content.", 400)
|
|
484
|
+
if re.search(r"(?m)^(<{7}|={7}|>{7}|\|{7})(?: |$)", content):
|
|
485
|
+
fail("git_conflict_markers", "Remove the conflict markers before saving and staging.")
|
|
486
|
+
self.checked_status(before["revision"])
|
|
487
|
+
self.save_resolution(path, content, conflict["result"].encode("utf-8"))
|
|
488
|
+
self.index_override = temporary
|
|
489
|
+
try:
|
|
490
|
+
self.git("add", "--", path)
|
|
491
|
+
staged, _, _ = self.git("show", ":" + path, limit=MAX_TEXT)
|
|
492
|
+
if staged != content.encode("utf-8") or self.read_worktree(path) != staged:
|
|
493
|
+
fail("git_stale_revision", "The conflict file changed while staging. Its newer content was preserved; refresh before staging again.")
|
|
494
|
+
except HTTPException as exc:
|
|
495
|
+
fail("git_resolution_saved_not_staged", "Resolution was saved but Git could not stage it. Refresh and stage it in the terminal. " + str(exc.detail))
|
|
496
|
+
elif action in {"continue", "abort"}:
|
|
497
|
+
operation = before["operation"]
|
|
498
|
+
if operation is None:
|
|
499
|
+
fail("git_no_operation", "There is no merge, rebase, cherry-pick, or revert to finish.")
|
|
500
|
+
if action == "abort" and request.get("confirmed") is not True:
|
|
501
|
+
fail("git_abort_confirmation_required", "Confirm aborting this Git operation before continuing.", 400)
|
|
502
|
+
if action == "continue" and before["conflict_count"]:
|
|
503
|
+
fail("git_unresolved_conflict", "Resolve and stage every conflict before continuing.")
|
|
504
|
+
self.index_override = temporary
|
|
505
|
+
self.publish_index_on_error = True
|
|
506
|
+
try:
|
|
507
|
+
self.git(operation, "--" + action)
|
|
508
|
+
except HTTPException as exc:
|
|
509
|
+
# Rebase/cherry-pick can legitimately stop at the next
|
|
510
|
+
# conflict. Their new index must accompany the new files.
|
|
511
|
+
unmerged, _, _ = self.git("ls-files", "--unmerged", "-z")
|
|
512
|
+
if action != "continue" or not unmerged or exc.detail.get("code") != "git_command_failed":
|
|
513
|
+
raise
|
|
514
|
+
else:
|
|
515
|
+
fail("git_invalid_action", "Unsupported Git action.", 400)
|
|
516
|
+
return self.status()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def register_workspace_git_routes(app: Any, *, authorize: Callable, workspace_root: Callable) -> None:
|
|
520
|
+
def repository(session_id: str, for_write: bool = False) -> Repository:
|
|
521
|
+
_, root = workspace_root(session_id, for_write=for_write)
|
|
522
|
+
return Repository(root)
|
|
523
|
+
|
|
524
|
+
@app.get("/api/sessions/{session_id}/workspace/git")
|
|
525
|
+
async def workspace_git_status(request: Request, session_id: str) -> dict[str, Any]:
|
|
526
|
+
authorize(request)
|
|
527
|
+
return await asyncio.to_thread(lambda: repository(session_id).status())
|
|
528
|
+
|
|
529
|
+
@app.get("/api/sessions/{session_id}/workspace/git/diff")
|
|
530
|
+
async def workspace_git_diff(request: Request, session_id: str,
|
|
531
|
+
path: str = Query(min_length=1, max_length=4096),
|
|
532
|
+
view: Literal["staged", "unstaged"] = "unstaged") -> dict[str, Any]:
|
|
533
|
+
authorize(request)
|
|
534
|
+
return await asyncio.to_thread(lambda: repository(session_id).diff(path, view))
|
|
535
|
+
|
|
536
|
+
@app.get("/api/sessions/{session_id}/workspace/git/conflict")
|
|
537
|
+
async def workspace_git_conflict(request: Request, session_id: str,
|
|
538
|
+
path: str = Query(min_length=1, max_length=4096)) -> dict[str, Any]:
|
|
539
|
+
authorize(request)
|
|
540
|
+
return await asyncio.to_thread(lambda: repository(session_id).conflict(path))
|
|
541
|
+
|
|
542
|
+
@app.post("/api/sessions/{session_id}/workspace/git/action")
|
|
543
|
+
async def workspace_git_action(request: Request, session_id: str, req: GitAction) -> dict[str, Any]:
|
|
544
|
+
authorize(request)
|
|
545
|
+
return await asyncio.to_thread(lambda: repository(session_id, True).action(req.model_dump()))
|