@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.
Files changed (39) hide show
  1. package/NOTICE +7 -0
  2. package/package.json +1 -1
  3. package/server/NOTICE +7 -0
  4. package/server/VERSION +1 -1
  5. package/server/agent_server.py +1658 -498
  6. package/server/agentsdock_jobs.py +2 -2
  7. package/server/claude_history_repair.py +46 -61
  8. package/server/codex_auth.py +76 -7
  9. package/server/codex_history_repair.py +203 -38
  10. package/server/cursor_process_guard.py +67 -4
  11. package/server/cursor_provider_mcp.py +2 -0
  12. package/server/execution_durability.py +87 -11
  13. package/server/execution_install.py +5 -1
  14. package/server/gemini_acp_client.py +432 -0
  15. package/server/gemini_api.py +128 -0
  16. package/server/gemini_content.py +102 -0
  17. package/server/gemini_history.py +293 -0
  18. package/server/gemini_import.py +126 -0
  19. package/server/gemini_permissions.py +47 -0
  20. package/server/gemini_runner.py +493 -0
  21. package/server/gemini_runtime.py +322 -0
  22. package/server/install.sh +26 -2
  23. package/server/interactive_chat_controls.py +9 -3
  24. package/server/interactive_chat_native.py +1 -1
  25. package/server/interactive_chat_share_routes.py +2 -2
  26. package/server/interactive_chat_share_web.py +2 -2
  27. package/server/local_session_ownership.py +1 -0
  28. package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
  29. package/server/npm/agentsdock/pair.cjs +123 -0
  30. package/server/npm/agentsdock/qr-renderer.cjs +15 -0
  31. package/server/provider_commands.py +65 -0
  32. package/server/provider_connections.py +39 -10
  33. package/server/public_chat_share_routes.py +8 -6
  34. package/server/public_chat_shares.py +17 -7
  35. package/server/server_instances.py +217 -15
  36. package/server/shared_chat_analytics_web.py +5 -0
  37. package/server/title_generation.py +60 -1
  38. package/server/transcript_reader.py +334 -0
  39. 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
- def flush_tree(root: Path, *, dependency_prefix: bool = False) -> dict[str, int]:
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 (dependency_prefix or len(relative) != 3 or relative[:2] != (".venv", "bin")
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
- external.add(resolved)
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
- for release in releases:
167
- if not release:
168
- continue
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
- continue
229
+ return
174
230
  seen.add(path)
175
- flush_tree(path)
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
- *(["SendSIGKILL=no"] if role == "worker" else []), "UMask=0077",
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