@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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. 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()))