@agentsdock/server 1.0.10 → 1.0.11-beta.12
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/NOTICE +7 -0
- package/package.json +1 -1
- package/server/NOTICE +7 -0
- package/server/VERSION +1 -1
- package/server/agent_server.py +1658 -498
- package/server/agentsdock_jobs.py +2 -2
- package/server/claude_history_repair.py +46 -61
- package/server/codex_auth.py +76 -7
- package/server/codex_history_repair.py +203 -38
- package/server/cursor_process_guard.py +67 -4
- package/server/cursor_provider_mcp.py +2 -0
- package/server/execution_durability.py +87 -11
- package/server/execution_install.py +5 -1
- package/server/gemini_acp_client.py +432 -0
- package/server/gemini_api.py +128 -0
- package/server/gemini_content.py +102 -0
- package/server/gemini_history.py +293 -0
- package/server/gemini_import.py +126 -0
- package/server/gemini_permissions.py +47 -0
- package/server/gemini_runner.py +493 -0
- package/server/gemini_runtime.py +322 -0
- package/server/install.sh +26 -2
- package/server/interactive_chat_controls.py +9 -3
- package/server/interactive_chat_native.py +1 -1
- package/server/interactive_chat_share_routes.py +2 -2
- package/server/interactive_chat_share_web.py +2 -2
- package/server/local_session_ownership.py +1 -0
- package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
- package/server/npm/agentsdock/pair.cjs +123 -0
- package/server/npm/agentsdock/qr-renderer.cjs +15 -0
- package/server/provider_commands.py +65 -0
- package/server/provider_connections.py +39 -10
- package/server/public_chat_share_routes.py +8 -6
- package/server/public_chat_shares.py +17 -7
- package/server/server_instances.py +217 -15
- package/server/shared_chat_analytics_web.py +5 -0
- package/server/title_generation.py +60 -1
- package/server/transcript_reader.py +334 -0
- package/server/update_runner.py +4 -2
|
@@ -7,6 +7,7 @@ No bytes, modes, links, configuration or service state are changed here.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import fcntl
|
|
10
|
+
from dataclasses import dataclass
|
|
10
11
|
import os
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
import re
|
|
@@ -41,7 +42,56 @@ def _full_barrier(path: Path) -> None:
|
|
|
41
42
|
os.close(descriptor)
|
|
42
43
|
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class _VenvDependency:
|
|
47
|
+
link: Path
|
|
48
|
+
target: Path
|
|
49
|
+
link_text: str
|
|
50
|
+
link_identity: tuple
|
|
51
|
+
target_identity: tuple
|
|
52
|
+
release_identity: dict
|
|
53
|
+
release_directory_identity: tuple
|
|
54
|
+
|
|
55
|
+
def check(self) -> None:
|
|
56
|
+
if (_identity(self.link.lstat()) != self.link_identity
|
|
57
|
+
or os.readlink(self.link) != self.link_text
|
|
58
|
+
or self.link.resolve(strict=True) != self.target
|
|
59
|
+
or _identity(self.target.lstat()) != self.target_identity
|
|
60
|
+
or _identity(self.target.parent.lstat()) != self.release_directory_identity
|
|
61
|
+
or not activation._release_matches(self.target.parent, self.release_identity)):
|
|
62
|
+
raise RuntimeError("durability venv dependency binding changed")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _venv_dependency(root: Path, release: Path) -> _VenvDependency | None:
|
|
66
|
+
link = release / ".venv"
|
|
67
|
+
if not link.is_symlink():
|
|
68
|
+
return None
|
|
69
|
+
info = link.lstat()
|
|
70
|
+
text = os.readlink(link)
|
|
71
|
+
target = Path(os.path.abspath(link.parent / text))
|
|
72
|
+
if release in link.resolve(strict=True).parents:
|
|
73
|
+
# Existing internal directory aliases are covered by the ordinary walk.
|
|
74
|
+
return None
|
|
75
|
+
# Pin only this direct runtime dependency of a journal-owned release. The
|
|
76
|
+
# dependency itself is newly observed, not inferred to be journal authority.
|
|
77
|
+
if (release.parent != root / "releases" or info.st_uid != os.getuid()
|
|
78
|
+
or target.name != ".venv" or target.parent.parent != root / "releases"
|
|
79
|
+
or target.parent == release or target.parent.name.startswith(".")
|
|
80
|
+
or target.resolve(strict=True) != target):
|
|
81
|
+
raise ValueError("durability venv dependency must be a direct sibling release directory")
|
|
82
|
+
target_info = target.lstat()
|
|
83
|
+
if (not stat.S_ISDIR(target_info.st_mode) or target_info.st_uid != os.getuid()
|
|
84
|
+
or target_info.st_dev != info.st_dev):
|
|
85
|
+
raise PermissionError("durability venv dependency ownership or device changed")
|
|
86
|
+
binding = _VenvDependency(link, target, text, _identity(info), _identity(target_info),
|
|
87
|
+
activation._release_identity(target.parent, target.parent),
|
|
88
|
+
_identity(target.parent.lstat()))
|
|
89
|
+
binding.check()
|
|
90
|
+
return binding
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def flush_tree(root: Path, *, dependency_prefix: bool = False,
|
|
94
|
+
_venv_binding: _VenvDependency | None = None) -> dict[str, int]:
|
|
45
95
|
"""Flush one owned tree without following directory links or special files."""
|
|
46
96
|
root = Path(root)
|
|
47
97
|
if not root.is_absolute() or root.resolve(strict=True) != root:
|
|
@@ -98,11 +148,18 @@ def flush_tree(root: Path, *, dependency_prefix: bool = False) -> dict[str, int]
|
|
|
98
148
|
resolved = child.resolve(strict=True)
|
|
99
149
|
if root not in resolved.parents:
|
|
100
150
|
relative = child.relative_to(root).parts
|
|
101
|
-
if (
|
|
151
|
+
if (_venv_binding is not None and not dependency_prefix
|
|
152
|
+
and child == root / ".venv" == _venv_binding.link
|
|
153
|
+
and resolved == _venv_binding.target):
|
|
154
|
+
# flush_activation has already flushed the exact real
|
|
155
|
+
# sibling tree, with no directory-link grant of its own.
|
|
156
|
+
_venv_binding.check()
|
|
157
|
+
elif (dependency_prefix or len(relative) != 3 or relative[:2] != (".venv", "bin")
|
|
102
158
|
or not re.fullmatch(r"python(?:[0-9]+(?:\.[0-9]+)*)?", relative[2])
|
|
103
159
|
or not re.fullmatch(r"python(?:[0-9]+(?:\.[0-9]+)*)?", resolved.name)):
|
|
104
160
|
raise ValueError("external durability link is not a Python interpreter")
|
|
105
|
-
|
|
161
|
+
else:
|
|
162
|
+
external.add(resolved)
|
|
106
163
|
if target != os.readlink(name, dir_fd=descriptor):
|
|
107
164
|
raise RuntimeError("durability link changed while flushing")
|
|
108
165
|
else:
|
|
@@ -151,6 +208,8 @@ def flush_tree(root: Path, *, dependency_prefix: bool = False) -> dict[str, int]
|
|
|
151
208
|
raise RuntimeError("durability tree changed after flush")
|
|
152
209
|
if _identity(root.lstat()) != _identity(root_info):
|
|
153
210
|
raise RuntimeError("durability root changed after flush")
|
|
211
|
+
if _venv_binding is not None:
|
|
212
|
+
_venv_binding.check()
|
|
154
213
|
return total
|
|
155
214
|
|
|
156
215
|
|
|
@@ -163,16 +222,16 @@ def flush_activation(root: Path, value: dict) -> None:
|
|
|
163
222
|
raise RuntimeError("runtime durability must precede native quiescing")
|
|
164
223
|
releases = [value["candidate_release"], value["old_release"], value["execution"]["old_worker_release"]]
|
|
165
224
|
seen: set[Path] = set()
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
path = activation._locate_release(release)
|
|
170
|
-
if path is None:
|
|
171
|
-
raise RuntimeError("durability runtime is not retained by the journal")
|
|
225
|
+
bindings: list[_VenvDependency] = []
|
|
226
|
+
|
|
227
|
+
def flush_release(path: Path, release: dict, binding=None) -> None:
|
|
172
228
|
if path in seen:
|
|
173
|
-
|
|
229
|
+
return
|
|
174
230
|
seen.add(path)
|
|
175
|
-
|
|
231
|
+
if binding is None:
|
|
232
|
+
flush_tree(path)
|
|
233
|
+
else:
|
|
234
|
+
flush_tree(path, _venv_binding=binding)
|
|
176
235
|
descriptor = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
177
236
|
try:
|
|
178
237
|
os.fsync(descriptor)
|
|
@@ -181,6 +240,23 @@ def flush_activation(root: Path, value: dict) -> None:
|
|
|
181
240
|
_full_barrier(path / "VERSION")
|
|
182
241
|
if not activation._release_matches(path, release):
|
|
183
242
|
raise RuntimeError("durability runtime identity changed")
|
|
243
|
+
|
|
244
|
+
for release in releases:
|
|
245
|
+
if not release:
|
|
246
|
+
continue
|
|
247
|
+
path = activation._locate_release(release)
|
|
248
|
+
if path is None:
|
|
249
|
+
raise RuntimeError("durability runtime is not retained by the journal")
|
|
250
|
+
if path in seen:
|
|
251
|
+
continue
|
|
252
|
+
binding = _venv_dependency(root, path)
|
|
253
|
+
if binding is not None:
|
|
254
|
+
bindings.append(binding)
|
|
255
|
+
flush_release(binding.target.parent, binding.release_identity)
|
|
256
|
+
binding.check()
|
|
257
|
+
flush_release(path, release, binding)
|
|
258
|
+
for binding in bindings:
|
|
259
|
+
binding.check()
|
|
184
260
|
descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
185
261
|
try:
|
|
186
262
|
os.fsync(descriptor)
|
|
@@ -313,7 +313,11 @@ def render_service(layout: ExecutionLayout, role: str) -> bytes:
|
|
|
313
313
|
lines += ["ExecStart=" + " ".join(_unit_word(arg, command=True) for arg in command),
|
|
314
314
|
"Restart=always", "RestartSec=2",
|
|
315
315
|
"TimeoutStopSec=180s" if role == "worker" else "TimeoutStopSec=10s",
|
|
316
|
-
|
|
316
|
+
# The worker owns provider cleanup and exclusive state leases.
|
|
317
|
+
# Detached user jobs must survive its replacement. control-group
|
|
318
|
+
# plus SendSIGKILL=no also prevents systemd from relaunching the
|
|
319
|
+
# worker while any such job remains after the worker exits.
|
|
320
|
+
*(["KillMode=process", "SendSIGKILL=no"] if role == "worker" else []), "UMask=0077",
|
|
317
321
|
"", "[Install]", "WantedBy=default.target", ""]
|
|
318
322
|
return "\n".join(lines).encode()
|
|
319
323
|
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Bounded Gemini CLI ACP transport; authentication is NOT performed here.
|
|
2
|
+
|
|
3
|
+
Each owner starts its own process and supplies an explicit cwd/environment.
|
|
4
|
+
In particular, catalog refresh must never call ACP authenticate: Gemini 0.64
|
|
5
|
+
persists the selected method and can clear the user's cached credentials.
|
|
6
|
+
Protocol baseline: google-gemini/gemini-cli v0.64.0, ACP protocol version 1.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from collections import deque
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Awaitable, Callable
|
|
19
|
+
|
|
20
|
+
MAX_LINE_BYTES = 4 * 1024 * 1024
|
|
21
|
+
MAX_STREAM_BYTES = 64 * 1024 * 1024
|
|
22
|
+
MAX_EVENTS = 100_000
|
|
23
|
+
MAX_REQUESTS = 32
|
|
24
|
+
IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,239}$")
|
|
25
|
+
SESSION_ID = re.compile(r"^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GeminiProtocolError(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class GeminiRPCError(GeminiProtocolError):
|
|
33
|
+
def __init__(self, code: int, message: str):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.code = code
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def safe_rpc_error(error: dict) -> str:
|
|
39
|
+
"""Classify known failures without echoing gateway text or credentials."""
|
|
40
|
+
message = str(error.get("message", "")).lower()
|
|
41
|
+
if "privileged approval modes in an untrusted folder" in message:
|
|
42
|
+
return "Gemini cannot enable this permission mode in an untrusted workspace. Review folder trust in native Gemini or choose Default; no prompt was sent."
|
|
43
|
+
if "invalid or unavailable mode" in message:
|
|
44
|
+
return "This Gemini permission mode is not enabled in the workspace. Choose an available native mode; no prompt was sent."
|
|
45
|
+
if "no longer supported" in message and "code assist for individuals" in message:
|
|
46
|
+
return ("Google no longer supports this Gemini CLI connection for Code Assist for individuals. "
|
|
47
|
+
"Use a Gemini Developer API key in My Agents. "
|
|
48
|
+
"Signing in to the same personal account again will not resolve this restriction.")
|
|
49
|
+
if error.get("code") == 402 or "prepayment credits are depleted" in message:
|
|
50
|
+
return "Gemini API project credits are depleted. Add credits or check billing in Google AI Studio, then retry."
|
|
51
|
+
if "no longer available" in message or "modelnotfound" in message:
|
|
52
|
+
return "This Gemini model is unavailable for the selected account. Choose another current model in the chat's model menu."
|
|
53
|
+
if "quota" in message or "resource_exhausted" in message or "rate limit" in message:
|
|
54
|
+
return "Gemini's selected account or API project has reached its quota. Check its billing/quota or retry later."
|
|
55
|
+
if error.get("code") == -32000 and ("auth" in message or "api key" in message):
|
|
56
|
+
return "Gemini API authentication is required. Configure its API key in My Agents, then retry."
|
|
57
|
+
return "Gemini rejected the request. Check the selected account, model and workspace."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def identifier(value: Any, *, session: bool = False) -> str:
|
|
61
|
+
pattern = SESSION_ID if session else IDENTIFIER
|
|
62
|
+
if not isinstance(value, str) or not pattern.fullmatch(value):
|
|
63
|
+
raise GeminiProtocolError("Gemini returned an invalid session/tool identifier.")
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def text_content(value: Any) -> str:
|
|
68
|
+
"""Only visible text, never decode opaque/encrypted/provider-private data."""
|
|
69
|
+
if not isinstance(value, dict):
|
|
70
|
+
return ""
|
|
71
|
+
if value.get("type") == "text" and isinstance(value.get("text"), str):
|
|
72
|
+
return value["text"]
|
|
73
|
+
if value.get("type") == "resource" and isinstance(value.get("resource"), dict):
|
|
74
|
+
text = value["resource"].get("text")
|
|
75
|
+
return text if isinstance(text, str) else ""
|
|
76
|
+
return ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def normalize_update(params: Any, session_id: str) -> dict | None:
|
|
80
|
+
if not isinstance(params, dict) or params.get("sessionId") != session_id:
|
|
81
|
+
raise GeminiProtocolError("Gemini update belongs to a different session.")
|
|
82
|
+
update = params.get("update")
|
|
83
|
+
if not isinstance(update, dict):
|
|
84
|
+
raise GeminiProtocolError("Gemini returned an invalid session update.")
|
|
85
|
+
kind = update.get("sessionUpdate")
|
|
86
|
+
if kind in {"agent_message_chunk", "agent_thought_chunk"}:
|
|
87
|
+
return {"type": "text" if kind == "agent_message_chunk" else "reasoning",
|
|
88
|
+
"text": text_content(update.get("content"))}
|
|
89
|
+
if kind in {"tool_call", "tool_call_update"}:
|
|
90
|
+
call_id = identifier(update.get("toolCallId"))
|
|
91
|
+
status = update.get("status")
|
|
92
|
+
if status is not None and status not in {"pending", "in_progress", "completed", "failed"}:
|
|
93
|
+
raise GeminiProtocolError("Gemini returned an invalid tool status.")
|
|
94
|
+
return {"type": "tool", "call_id": call_id, "status": status,
|
|
95
|
+
"title": update.get("title"), "kind": update.get("kind"),
|
|
96
|
+
"input": update.get("rawInput"), "output": update.get("rawOutput"),
|
|
97
|
+
"content": update.get("content"), "locations": update.get("locations")}
|
|
98
|
+
# user_message_chunk includes historical replay and provider reinjections.
|
|
99
|
+
# It is never evidence of a new user-authored AgentsDock turn.
|
|
100
|
+
if kind == "user_message_chunk":
|
|
101
|
+
return None
|
|
102
|
+
if kind == "plan":
|
|
103
|
+
return {"type": "plan", "entries": update.get("entries")}
|
|
104
|
+
if kind == "usage_update":
|
|
105
|
+
return {"type": "usage", "usage": update}
|
|
106
|
+
if kind == "current_mode_update":
|
|
107
|
+
mode = update.get("currentModeId")
|
|
108
|
+
if mode not in {"default", "autoEdit", "yolo", "plan"}:
|
|
109
|
+
raise GeminiProtocolError("Gemini reported an unknown permission mode.")
|
|
110
|
+
return {"type": "permission_mode", "mode": mode}
|
|
111
|
+
# Model/mode/command notifications aren't conversation text.
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def session_models(result: dict) -> dict:
|
|
116
|
+
value = result.get("models")
|
|
117
|
+
if not isinstance(value, dict):
|
|
118
|
+
return {"models": [], "default_model": ""}
|
|
119
|
+
options = value.get("availableModels")
|
|
120
|
+
if not isinstance(options, list) or len(options) > 512:
|
|
121
|
+
raise GeminiProtocolError("Gemini returned an invalid model catalog.")
|
|
122
|
+
models = []
|
|
123
|
+
seen = set()
|
|
124
|
+
for option in options:
|
|
125
|
+
if not isinstance(option, dict):
|
|
126
|
+
raise GeminiProtocolError("Gemini returned an invalid model option.")
|
|
127
|
+
model = option.get("modelId")
|
|
128
|
+
name = option.get("name")
|
|
129
|
+
if (not isinstance(model, str) or not model or len(model) > 256
|
|
130
|
+
or any(ord(c) < 33 or ord(c) == 127 for c in model)
|
|
131
|
+
or not isinstance(name, str) or len(name) > 256
|
|
132
|
+
or any(ord(c) < 32 or ord(c) == 127 for c in name)):
|
|
133
|
+
raise GeminiProtocolError("Gemini returned an invalid model option.")
|
|
134
|
+
if model not in seen:
|
|
135
|
+
models.append({"value": model, "label": name or model})
|
|
136
|
+
seen.add(model)
|
|
137
|
+
current = value.get("currentModelId")
|
|
138
|
+
return {"models": models, "default_model": current if current in seen else ""}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class GeminiACPClient:
|
|
142
|
+
"""One process owner. Requests, permission replies and Stop share one pipe."""
|
|
143
|
+
|
|
144
|
+
def __init__(self, *, on_update: Callable[[dict], Awaitable[None]],
|
|
145
|
+
on_permission: Callable[[dict], Awaitable[dict]] | None = None):
|
|
146
|
+
self.on_update = on_update
|
|
147
|
+
self.on_permission = on_permission
|
|
148
|
+
self.process: asyncio.subprocess.Process | None = None
|
|
149
|
+
self.session_id: str | None = None
|
|
150
|
+
self.replaying = False
|
|
151
|
+
self.replay_expected = deque()
|
|
152
|
+
self.replay_done = asyncio.Event()
|
|
153
|
+
self.pending: dict[int, asyncio.Future] = {}
|
|
154
|
+
self.counter = 0
|
|
155
|
+
self.reader: asyncio.Task | None = None
|
|
156
|
+
self.stderr: asyncio.Task | None = None
|
|
157
|
+
self.permissions: dict[Any, asyncio.Task] = {}
|
|
158
|
+
self.write_lock = asyncio.Lock()
|
|
159
|
+
self.closed = False
|
|
160
|
+
self.cleanup_complete = False
|
|
161
|
+
self.close_lock = asyncio.Lock()
|
|
162
|
+
self.guard_ready = asyncio.Event()
|
|
163
|
+
self.guard_ready_verified = False
|
|
164
|
+
self.failure: Exception | None = None
|
|
165
|
+
self.capabilities: dict = {}
|
|
166
|
+
self.requested_mode: str | None = None
|
|
167
|
+
|
|
168
|
+
async def start(self, command: list[str], *, cwd: str, env: dict[str, str],
|
|
169
|
+
on_started: Callable[[asyncio.subprocess.Process], Awaitable[None]] | None = None) -> dict:
|
|
170
|
+
if self.process is not None or self.closed:
|
|
171
|
+
raise GeminiProtocolError("Gemini client cannot be started twice.")
|
|
172
|
+
spawning = asyncio.create_task(asyncio.create_subprocess_exec(
|
|
173
|
+
sys.executable, str(Path(__file__).with_name("cursor_process_guard.py")),
|
|
174
|
+
"--verify-cleanup", "--ready-notice", "--parent-pid", str(os.getpid()), "--", *command,
|
|
175
|
+
cwd=cwd, env=env, stdin=asyncio.subprocess.PIPE,
|
|
176
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
177
|
+
limit=MAX_LINE_BYTES, start_new_session=True,
|
|
178
|
+
))
|
|
179
|
+
cancelled = False
|
|
180
|
+
while True:
|
|
181
|
+
try:
|
|
182
|
+
# Stop can arrive while asyncio is constructing the transport.
|
|
183
|
+
# Retain its process handle before propagating cancellation so
|
|
184
|
+
# the runner's finally block can verify owned-tree cleanup.
|
|
185
|
+
self.process = await asyncio.shield(spawning)
|
|
186
|
+
break
|
|
187
|
+
except asyncio.CancelledError:
|
|
188
|
+
if spawning.cancelled():
|
|
189
|
+
raise
|
|
190
|
+
cancelled = True
|
|
191
|
+
self.reader = asyncio.create_task(self._read())
|
|
192
|
+
self.stderr = asyncio.create_task(self._drain_stderr())
|
|
193
|
+
if cancelled:
|
|
194
|
+
raise asyncio.CancelledError()
|
|
195
|
+
if on_started:
|
|
196
|
+
await on_started(self.process)
|
|
197
|
+
result = await self.request("initialize", {
|
|
198
|
+
"protocolVersion": 1,
|
|
199
|
+
"clientInfo": {"name": "agentsdock", "version": "1"},
|
|
200
|
+
"clientCapabilities": {}, # Do not advertise an unimplemented FS/terminal proxy.
|
|
201
|
+
})
|
|
202
|
+
if result.get("protocolVersion") != 1:
|
|
203
|
+
raise GeminiProtocolError("Unsupported Gemini ACP protocol version.")
|
|
204
|
+
self.capabilities = result.get("agentCapabilities") or {}
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
async def open_session(self, *, cwd: str, session_id: str | None = None,
|
|
208
|
+
mcp_servers: list[dict] | None = None,
|
|
209
|
+
replay: list[tuple[str, str]] | None = None) -> dict:
|
|
210
|
+
if self.session_id is not None:
|
|
211
|
+
raise GeminiProtocolError("This Gemini process already owns a session.")
|
|
212
|
+
params: dict = {"cwd": cwd, "mcpServers": mcp_servers or []}
|
|
213
|
+
if session_id:
|
|
214
|
+
if self.capabilities.get("loadSession") is not True:
|
|
215
|
+
raise GeminiProtocolError("This Gemini CLI cannot resume sessions.")
|
|
216
|
+
self.session_id = identifier(session_id, session=True)
|
|
217
|
+
if replay is None:
|
|
218
|
+
raise GeminiProtocolError("Gemini resume requires a verified native replay snapshot.")
|
|
219
|
+
self.replay_expected = deque(replay)
|
|
220
|
+
self.replay_done.clear()
|
|
221
|
+
if not replay:
|
|
222
|
+
self.replay_done.set()
|
|
223
|
+
self.replaying = True
|
|
224
|
+
try:
|
|
225
|
+
result = await self.request("session/load", {**params, "sessionId": self.session_id})
|
|
226
|
+
await asyncio.wait_for(self.replay_done.wait(), 45)
|
|
227
|
+
if self.failure:
|
|
228
|
+
raise self.failure
|
|
229
|
+
finally:
|
|
230
|
+
self.replaying = False
|
|
231
|
+
else:
|
|
232
|
+
result = await self.request("session/new", params)
|
|
233
|
+
self.session_id = identifier(result.get("sessionId"), session=True)
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
async def prompt(self, blocks: list[dict], *, timeout: float = 3600) -> dict:
|
|
237
|
+
if not self.session_id:
|
|
238
|
+
raise GeminiProtocolError("Gemini session has not been opened.")
|
|
239
|
+
return await self.request("session/prompt", {
|
|
240
|
+
"sessionId": self.session_id, "prompt": blocks,
|
|
241
|
+
}, timeout=timeout)
|
|
242
|
+
|
|
243
|
+
async def cancel(self) -> None:
|
|
244
|
+
if self.session_id and not self.closed:
|
|
245
|
+
await self._send({"jsonrpc": "2.0", "method": "session/cancel",
|
|
246
|
+
"params": {"sessionId": self.session_id}})
|
|
247
|
+
|
|
248
|
+
async def request(self, method: str, params: dict, *, timeout: float = 45) -> dict:
|
|
249
|
+
if self.closed or self.failure or len(self.pending) >= MAX_REQUESTS:
|
|
250
|
+
raise GeminiProtocolError("Gemini connection is unavailable.") from self.failure
|
|
251
|
+
# Calling authenticate is an explicit account mutation, never an ordinary
|
|
252
|
+
# admission/recheck step. A dedicated isolated API profile is required.
|
|
253
|
+
if method == "authenticate":
|
|
254
|
+
raise GeminiProtocolError("Authentication mutation is not allowed on this transport.")
|
|
255
|
+
if method == "session/set_mode":
|
|
256
|
+
if self.requested_mode is not None or params.get("modeId") not in {"default", "autoEdit", "yolo", "plan"}:
|
|
257
|
+
raise GeminiProtocolError("Invalid or overlapping Gemini mode change.")
|
|
258
|
+
self.requested_mode = params["modeId"]
|
|
259
|
+
self.counter += 1
|
|
260
|
+
request_id = self.counter
|
|
261
|
+
future = asyncio.get_running_loop().create_future()
|
|
262
|
+
self.pending[request_id] = future
|
|
263
|
+
try:
|
|
264
|
+
await self._send({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params})
|
|
265
|
+
return await asyncio.wait_for(future, timeout)
|
|
266
|
+
finally:
|
|
267
|
+
self.pending.pop(request_id, None)
|
|
268
|
+
if method == "session/set_mode":
|
|
269
|
+
self.requested_mode = None
|
|
270
|
+
if not future.done():
|
|
271
|
+
future.cancel()
|
|
272
|
+
|
|
273
|
+
async def _send(self, payload: dict) -> None:
|
|
274
|
+
encoded = (json.dumps(payload, ensure_ascii=False, allow_nan=False) + "\n").encode()
|
|
275
|
+
if len(encoded) > MAX_LINE_BYTES:
|
|
276
|
+
raise GeminiProtocolError("Gemini request exceeds the protocol size limit.")
|
|
277
|
+
async with self.write_lock:
|
|
278
|
+
if self.closed or not self.process or not self.process.stdin:
|
|
279
|
+
raise GeminiProtocolError("Gemini connection is closed.")
|
|
280
|
+
self.process.stdin.write(encoded)
|
|
281
|
+
await asyncio.wait_for(self.process.stdin.drain(), 10)
|
|
282
|
+
|
|
283
|
+
async def _read(self) -> None:
|
|
284
|
+
count = total = 0
|
|
285
|
+
try:
|
|
286
|
+
assert self.process and self.process.stdout
|
|
287
|
+
while raw := await self.process.stdout.readline():
|
|
288
|
+
count += 1
|
|
289
|
+
total += len(raw)
|
|
290
|
+
if len(raw) > MAX_LINE_BYTES or total > MAX_STREAM_BYTES or count > MAX_EVENTS:
|
|
291
|
+
raise GeminiProtocolError("Gemini exceeded the bounded protocol stream limit.")
|
|
292
|
+
try:
|
|
293
|
+
event = json.loads(raw)
|
|
294
|
+
except (ValueError, UnicodeError):
|
|
295
|
+
raise GeminiProtocolError("Gemini emitted invalid JSON-RPC.") from None
|
|
296
|
+
if not isinstance(event, dict) or event.get("jsonrpc") != "2.0":
|
|
297
|
+
raise GeminiProtocolError("Gemini emitted an invalid JSON-RPC envelope.")
|
|
298
|
+
method = event.get("method")
|
|
299
|
+
if method is not None:
|
|
300
|
+
if not isinstance(method, str):
|
|
301
|
+
raise GeminiProtocolError("Gemini emitted an invalid method.")
|
|
302
|
+
if "id" in event:
|
|
303
|
+
key = event["id"]
|
|
304
|
+
if not (type(key) is int or isinstance(key, str) and 0 < len(key) < 256):
|
|
305
|
+
raise GeminiProtocolError("Gemini emitted an invalid request identifier.")
|
|
306
|
+
if key in self.permissions or len(self.permissions) >= MAX_REQUESTS:
|
|
307
|
+
raise GeminiProtocolError("Gemini exceeded the outstanding request limit.")
|
|
308
|
+
task = asyncio.create_task(self._answer(key, method, event.get("params")))
|
|
309
|
+
self.permissions[key] = task
|
|
310
|
+
task.add_done_callback(lambda _task, key=key: self.permissions.pop(key, None))
|
|
311
|
+
elif method == "session/update":
|
|
312
|
+
params = event.get("params")
|
|
313
|
+
# Command notifications may arrive immediately after new;
|
|
314
|
+
# no model is prompted until open_session has completed.
|
|
315
|
+
if self.session_id is not None:
|
|
316
|
+
update = normalize_update(params, self.session_id)
|
|
317
|
+
# Pinned CLI emits its set_mode acknowledgement as
|
|
318
|
+
# text rather than ACP current_mode_update. Correlate
|
|
319
|
+
# only the exact notice during our own RPC, never
|
|
320
|
+
# suppress matching model replies or history replay.
|
|
321
|
+
if (self.requested_mode is not None and not self.replaying
|
|
322
|
+
and update == {"type": "text", "text": f"[MODE_UPDATE] {self.requested_mode}"}):
|
|
323
|
+
update = {"type": "permission_mode", "mode": self.requested_mode}
|
|
324
|
+
if self.replaying and params["update"].get("sessionUpdate") in {
|
|
325
|
+
"user_message_chunk", "agent_message_chunk", "agent_thought_chunk", "tool_call", "tool_call_update"
|
|
326
|
+
}:
|
|
327
|
+
from gemini_history import fingerprint
|
|
328
|
+
if not self.replay_expected or fingerprint(params["update"]) != self.replay_expected.popleft():
|
|
329
|
+
raise GeminiProtocolError("Gemini history replay changed; refusing to mix it with a new turn.")
|
|
330
|
+
if not self.replay_expected:
|
|
331
|
+
self.replay_done.set()
|
|
332
|
+
if update is not None and not self.replaying:
|
|
333
|
+
await self.on_update(update)
|
|
334
|
+
continue
|
|
335
|
+
key = event.get("id")
|
|
336
|
+
if type(key) is not int:
|
|
337
|
+
raise GeminiProtocolError("Gemini emitted an invalid response identifier.")
|
|
338
|
+
future = self.pending.get(key)
|
|
339
|
+
if future is None or future.done():
|
|
340
|
+
continue # A late response to a timed-out/cancelled request.
|
|
341
|
+
error = event.get("error")
|
|
342
|
+
if isinstance(error, dict):
|
|
343
|
+
code = error.get("code")
|
|
344
|
+
# Do not include raw RPC messages: gateways can echo keys.
|
|
345
|
+
future.set_exception(GeminiRPCError(code if type(code) is int else -32000, safe_rpc_error(error)))
|
|
346
|
+
elif isinstance(event.get("result"), dict):
|
|
347
|
+
future.set_result(event["result"])
|
|
348
|
+
else:
|
|
349
|
+
raise GeminiProtocolError("Gemini emitted an invalid response.")
|
|
350
|
+
raise GeminiProtocolError("Gemini disconnected before the request completed.")
|
|
351
|
+
except asyncio.CancelledError:
|
|
352
|
+
raise
|
|
353
|
+
except Exception as exc:
|
|
354
|
+
self.failure = exc if isinstance(exc, GeminiProtocolError) else GeminiProtocolError("Gemini protocol connection failed.")
|
|
355
|
+
self.replay_done.set()
|
|
356
|
+
for future in tuple(self.pending.values()):
|
|
357
|
+
if not future.done():
|
|
358
|
+
future.set_exception(self.failure)
|
|
359
|
+
|
|
360
|
+
async def _answer(self, key: Any, method: str, params: Any) -> None:
|
|
361
|
+
try:
|
|
362
|
+
if method != "session/request_permission":
|
|
363
|
+
await self._send({"jsonrpc": "2.0", "id": key,
|
|
364
|
+
"error": {"code": -32601, "message": "Unsupported client method"}})
|
|
365
|
+
return
|
|
366
|
+
result = {"outcome": {"outcome": "cancelled"}}
|
|
367
|
+
if (not self.replaying and self.session_id and isinstance(params, dict)
|
|
368
|
+
and params.get("sessionId") == self.session_id and self.on_permission):
|
|
369
|
+
result = await self.on_permission(params)
|
|
370
|
+
await self._send({"jsonrpc": "2.0", "id": key, "result": result})
|
|
371
|
+
except asyncio.CancelledError:
|
|
372
|
+
raise
|
|
373
|
+
except Exception:
|
|
374
|
+
# Permission callbacks must fail closed, never silently auto-approve.
|
|
375
|
+
with suppress(Exception):
|
|
376
|
+
await self._send({"jsonrpc": "2.0", "id": key,
|
|
377
|
+
"result": {"outcome": {"outcome": "cancelled"}}})
|
|
378
|
+
|
|
379
|
+
async def _drain_stderr(self) -> None:
|
|
380
|
+
assert self.process and self.process.stderr
|
|
381
|
+
from cursor_process_guard import READY_NOTICE
|
|
382
|
+
try:
|
|
383
|
+
self.guard_ready_verified = await self.process.stderr.readline() == READY_NOTICE
|
|
384
|
+
finally:
|
|
385
|
+
self.guard_ready.set()
|
|
386
|
+
# Native stderr can include credentials, browser URLs and prompts. Drain
|
|
387
|
+
# without retaining or publishing it; use structured, sanitized errors.
|
|
388
|
+
while await self.process.stderr.read(16 * 1024):
|
|
389
|
+
pass
|
|
390
|
+
|
|
391
|
+
async def close(self) -> None:
|
|
392
|
+
async with self.close_lock:
|
|
393
|
+
await self._close_owned()
|
|
394
|
+
|
|
395
|
+
async def _close_owned(self) -> None:
|
|
396
|
+
if self.cleanup_complete:
|
|
397
|
+
return
|
|
398
|
+
self.closed = True
|
|
399
|
+
if self.process:
|
|
400
|
+
# Never TERM the guard before its handlers are installed. An early
|
|
401
|
+
# Stop used to kill the wrapper with -SIGTERM and permanently fence
|
|
402
|
+
# the chat, with no trustworthy evidence about child cleanup.
|
|
403
|
+
try:
|
|
404
|
+
await asyncio.wait_for(self.guard_ready.wait(), 5)
|
|
405
|
+
except asyncio.TimeoutError:
|
|
406
|
+
raise GeminiProtocolError("Gemini runtime cleanup has not completed.") from None
|
|
407
|
+
if not self.guard_ready_verified:
|
|
408
|
+
raise GeminiProtocolError("Gemini runtime cleanup could not be verified.")
|
|
409
|
+
tasks = [task for task in (self.reader, self.stderr, *self.permissions.values()) if task]
|
|
410
|
+
for task in tasks:
|
|
411
|
+
task.cancel()
|
|
412
|
+
if tasks:
|
|
413
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
414
|
+
for future in tuple(self.pending.values()):
|
|
415
|
+
if not future.done():
|
|
416
|
+
future.set_exception(GeminiProtocolError("Gemini connection closed."))
|
|
417
|
+
proc = self.process
|
|
418
|
+
if proc:
|
|
419
|
+
if proc.stdin:
|
|
420
|
+
proc.stdin.close()
|
|
421
|
+
# The guard owns the CLI's separate group, tracks detached children
|
|
422
|
+
# and survives an abrupt server death. Give it time to verify exit;
|
|
423
|
+
# killing the guard itself is NOT evidence that tools have stopped.
|
|
424
|
+
with suppress(ProcessLookupError):
|
|
425
|
+
proc.terminate()
|
|
426
|
+
try:
|
|
427
|
+
await asyncio.wait_for(proc.wait(), 5)
|
|
428
|
+
except asyncio.TimeoutError:
|
|
429
|
+
raise GeminiProtocolError("Gemini runtime cleanup has not completed.") from None
|
|
430
|
+
if proc.returncode not in {0, 71, 143}:
|
|
431
|
+
raise GeminiProtocolError("Gemini runtime cleanup could not be verified.")
|
|
432
|
+
self.cleanup_complete = True
|