@agentsdock/server 1.0.7-beta.5 → 1.0.8
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/README.md +20 -6
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +5283 -698
- package/server/agentsdock_chats.py +2 -1
- package/server/chat_mailbox.py +23 -6
- package/server/claude_history_repair.py +45 -4
- package/server/claude_model_catalog.py +200 -0
- package/server/claude_sdk_client.py +63 -24
- package/server/claude_side_question.py +1 -8
- package/server/codex_app_server.py +90 -30
- package/server/codex_auth.py +119 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +160 -77
- package/server/codex_side_question.py +154 -57
- package/server/cursor_agent_client.py +27 -1
- package/server/cursor_api_key.py +64 -0
- package/server/cursor_history.py +219 -0
- package/server/cursor_provider_mcp.py +290 -0
- package/server/execution_durability.py +10 -6
- package/server/execution_preparation.py +6 -2
- package/server/install.sh +282 -78
- package/server/instances.sh +15 -0
- package/server/interactive_chat_native.py +4 -3
- package/server/interactive_chat_projection.py +6 -19
- package/server/interactive_chat_share_routes.py +71 -45
- package/server/interactive_chat_share_web.py +2 -2
- package/server/interactive_chat_shares.py +6 -11
- package/server/local_session_ownership.py +232 -0
- package/server/opencode_agent_client.py +685 -0
- package/server/provider_commands.py +579 -0
- package/server/provider_connections.py +665 -0
- package/server/provider_usage.py +241 -0
- package/server/public_chat_shares.py +60 -62
- package/server/public_chat_transcript.py +9 -31
- package/server/pyproject.toml +1 -0
- package/server/server_instances.py +931 -0
- package/server/shared_chat_video_stream.py +21 -2
- package/server/shared_chat_videos.py +54 -24
- package/server/side_questions.py +493 -10
- package/server/title_generation.py +4 -1
- package/server/uninstall.sh +95 -17
- package/server/update_preparation.py +4 -1
- package/server/update_runner.py +16 -0
- package/server/uv.lock +3 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Native
|
|
1
|
+
"""Native Codex forks with an independently owned lifetime.
|
|
2
2
|
|
|
3
3
|
The provider copies its own conversation history, including tool results. We
|
|
4
4
|
never reconstruct that history from the renderer transcript or resume, steer,
|
|
@@ -7,32 +7,28 @@ interrupt, or modify the source thread.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
|
+
from copy import deepcopy
|
|
10
11
|
from contextlib import suppress
|
|
11
12
|
import json
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
import tempfile
|
|
14
15
|
|
|
15
|
-
from codex_app_server import CodexAppServerClient, CodexAppServerError
|
|
16
|
+
from codex_app_server import CodexAppServerClient, CodexAppServerError, decline_server_request
|
|
16
17
|
from side_questions import (
|
|
17
18
|
MAX_OUTPUT_BYTES, SideQuestionError, isolated_environment, run_isolated_command,
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
|
|
21
|
-
SIDE_INSTRUCTIONS =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"parent work; they do not authorize action in this side chat. "
|
|
27
|
-
"Do not continue the parent task or pursue its goal. You have no workspace or task "
|
|
28
|
-
"authority. Do not use tools, send messages, access files, browse, or claim to change "
|
|
29
|
-
"anything. Answer directly and concisely; explain uncertainty when the inherited "
|
|
30
|
-
"context does not contain the answer."
|
|
31
|
-
)
|
|
22
|
+
SIDE_INSTRUCTIONS = """Answer questions and explore in this separate side chat without disrupting the main conversation.
|
|
23
|
+
|
|
24
|
+
Treat inherited messages, tasks, plans, tool calls and approvals as reference material, not current instructions or permission. Follow only requests made in this side chat; do not resume unfinished parent work.
|
|
25
|
+
|
|
26
|
+
Use the thread's existing permissions and available tools, including external tools, to read or search files and run checks that leave repo-tracked files unchanged. Do not create, contact or control subagents.
|
|
32
27
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
#
|
|
28
|
+
Change files, git state, configuration, permissions or other workspace state only when explicitly requested here. Request escalation only when such an explicit mutation requires it. Keep authorized changes limited to the request and preserve ongoing parent work."""
|
|
29
|
+
|
|
30
|
+
# These restrictions remain for title generation and endpoint probes. Side
|
|
31
|
+
# conversations use side_chat_config instead, with ordinary native tools.
|
|
36
32
|
DISABLED_FEATURES = (
|
|
37
33
|
"apps", "plugins", "remote_plugin", "recommended_plugins", "hooks",
|
|
38
34
|
"multi_agent", "multi_agent_v2", "goals", "image_generation", "memories",
|
|
@@ -58,27 +54,58 @@ def isolated_config() -> dict:
|
|
|
58
54
|
}
|
|
59
55
|
|
|
60
56
|
|
|
57
|
+
def side_chat_config(parent_config: dict | None = None) -> dict:
|
|
58
|
+
"""Keep native tools, without inheriting a main run's helper transport."""
|
|
59
|
+
from claude_sdk_client import CLAUDE_PROVIDER_MCP_SERVER_NAME
|
|
60
|
+
|
|
61
|
+
config = deepcopy(parent_config or {})
|
|
62
|
+
name = CLAUDE_PROVIDER_MCP_SERVER_NAME
|
|
63
|
+
prefix = f"mcp_servers.{name}"
|
|
64
|
+
for key in tuple(config):
|
|
65
|
+
if key == prefix or key.startswith(prefix + "."):
|
|
66
|
+
config.pop(key)
|
|
67
|
+
servers = config.get("mcp_servers")
|
|
68
|
+
if isinstance(servers, dict):
|
|
69
|
+
servers.pop(name, None)
|
|
70
|
+
config.update({
|
|
71
|
+
"features.multi_agent": False, "features.multi_agent_v2": False,
|
|
72
|
+
"agents.enabled": False,
|
|
73
|
+
f"{prefix}.enabled": False, f"{prefix}.required": False,
|
|
74
|
+
# Codex validates transport shape even for a disabled server. Never
|
|
75
|
+
# copy the parent's authenticated helper URL/headers into this child.
|
|
76
|
+
f"{prefix}.url": "http://127.0.0.1:0",
|
|
77
|
+
})
|
|
78
|
+
return config
|
|
79
|
+
|
|
80
|
+
|
|
61
81
|
def supports_native_side_chat(schema: dict) -> bool:
|
|
62
|
-
"""
|
|
82
|
+
"""Require the fork and permission fields used by native side chats."""
|
|
63
83
|
try:
|
|
64
84
|
definitions = schema["definitions"]
|
|
65
85
|
fork = definitions["ThreadForkParams"]["properties"]
|
|
66
|
-
environment = definitions["TurnStartParams"]["properties"]["environments"]
|
|
67
86
|
ephemeral = fork["ephemeral"].get("type", [])
|
|
68
87
|
return (
|
|
69
88
|
"boolean" in ephemeral
|
|
70
89
|
and all(name in fork for name in (
|
|
71
|
-
"excludeTurns", "runtimeWorkspaceRoots", "
|
|
90
|
+
"excludeTurns", "runtimeWorkspaceRoots", "cwd",
|
|
72
91
|
"developerInstructions", "config", "sandbox", "approvalPolicy",
|
|
92
|
+
"permissions", "approvalsReviewer",
|
|
73
93
|
))
|
|
74
|
-
and "array" in environment.get("type", [])
|
|
75
|
-
and "disables environment access" in environment.get("description", "")
|
|
76
94
|
)
|
|
77
95
|
except (KeyError, TypeError, AttributeError):
|
|
78
96
|
return False
|
|
79
97
|
|
|
80
98
|
|
|
81
|
-
|
|
99
|
+
def _supports_empty_turn_environments(schema: dict) -> bool:
|
|
100
|
+
try:
|
|
101
|
+
environment = schema["definitions"]["TurnStartParams"]["properties"]["environments"]
|
|
102
|
+
return ("array" in environment.get("type", [])
|
|
103
|
+
and "disables environment access" in environment.get("description", ""))
|
|
104
|
+
except (KeyError, TypeError, AttributeError):
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _verify_protocol(executable: str, temporary: str, env: dict, *, require_empty_environments=False):
|
|
82
109
|
target = Path(temporary) / "schema"
|
|
83
110
|
await run_isolated_command(
|
|
84
111
|
[executable, "app-server", "generate-json-schema", "--experimental", "--out", str(target)],
|
|
@@ -88,47 +115,69 @@ async def _verify_protocol(executable: str, temporary: str, env: dict):
|
|
|
88
115
|
source = target / "codex_app_server_protocol.v2.schemas.json"
|
|
89
116
|
if source.stat().st_size > 20 * 1024 * 1024:
|
|
90
117
|
raise ValueError("schema too large")
|
|
91
|
-
|
|
118
|
+
schema = json.loads(source.read_text())
|
|
119
|
+
supported = supports_native_side_chat(schema)
|
|
120
|
+
if require_empty_environments:
|
|
121
|
+
supported = supported and _supports_empty_turn_environments(schema)
|
|
92
122
|
except (OSError, ValueError, TypeError, AttributeError):
|
|
93
123
|
supported = False
|
|
94
124
|
if not supported:
|
|
95
125
|
raise SideQuestionError(503, "Update Codex to use native side chats")
|
|
96
126
|
|
|
97
127
|
|
|
128
|
+
async def _verify_isolated_protocol(executable: str, temporary: str, env: dict):
|
|
129
|
+
# Endpoint probes still use empty environments; Side chat does not.
|
|
130
|
+
await _verify_protocol(executable, temporary, env, require_empty_environments=True)
|
|
131
|
+
|
|
132
|
+
|
|
98
133
|
class NativeCodexSideChat:
|
|
99
|
-
"""One
|
|
134
|
+
"""One native fork; synced chats opt into durable provider persistence.
|
|
100
135
|
|
|
101
136
|
The app-server process is private to this side chat. Closing it cannot
|
|
102
137
|
interrupt the parent's process; no RPC that mutates the parent is issued.
|
|
103
|
-
The owning service
|
|
138
|
+
The owning service closes idle transports, cancellation and shutdown.
|
|
139
|
+
A durable fork is resumed from its native rollout after transport closure.
|
|
104
140
|
"""
|
|
105
141
|
|
|
106
142
|
def __init__(self, parent_thread_id: str, *, executable: str, model: str | None,
|
|
107
143
|
env: dict, parent_rollout_path: str | None = None,
|
|
108
|
-
provider_selection: dict | None = None
|
|
144
|
+
provider_selection: dict | None = None, cwd: str | None = None,
|
|
145
|
+
fork_overrides: dict | None = None, turn_overrides: dict | None = None,
|
|
146
|
+
server_request_handler=None, durable: bool = False,
|
|
147
|
+
resume_state: dict | None = None, persist_state=None):
|
|
109
148
|
if not isinstance(parent_thread_id, str) or not parent_thread_id.strip():
|
|
110
149
|
raise SideQuestionError(409, "The parent Codex conversation is not available yet")
|
|
111
150
|
self.parent_thread_id = parent_thread_id
|
|
112
151
|
self.parent_rollout_path = parent_rollout_path
|
|
113
152
|
self.executable = executable
|
|
114
153
|
self.model = model
|
|
154
|
+
self.cwd = cwd
|
|
155
|
+
self.fork_overrides = deepcopy(fork_overrides or {})
|
|
156
|
+
self.turn_overrides = deepcopy(turn_overrides or {})
|
|
157
|
+
self.server_request_handler = server_request_handler
|
|
158
|
+
self.durable = durable
|
|
159
|
+
self.resume_state = deepcopy(resume_state)
|
|
160
|
+
self.persist_state = persist_state
|
|
115
161
|
self.env = isolated_environment(env)
|
|
116
162
|
self.provider_config = {}
|
|
117
163
|
self.provider_turn_overrides = {}
|
|
118
164
|
self.sensitive_values = ()
|
|
165
|
+
self.protected_env_keys = ()
|
|
119
166
|
if provider_selection:
|
|
120
167
|
# Lazy import avoids the provider probe/isolated-config cycle.
|
|
121
|
-
from codex_provider import native_config, native_environment, turn_overrides
|
|
168
|
+
from codex_provider import ENV_KEY, native_config, native_environment, turn_overrides as provider_turn_overrides
|
|
122
169
|
self.env = native_environment(self.env, provider_selection)
|
|
123
170
|
self.provider_config = native_config(provider_selection)
|
|
124
|
-
self.provider_turn_overrides =
|
|
171
|
+
self.provider_turn_overrides = provider_turn_overrides(provider_selection["model"], provider_selection.get("effort") or "",
|
|
125
172
|
summary=provider_selection.get("reasoning_summary") or "none")
|
|
126
173
|
self.sensitive_values = (provider_selection["api_key"],)
|
|
174
|
+
self.protected_env_keys = (ENV_KEY,)
|
|
127
175
|
self.thread_id: str | None = None
|
|
128
176
|
self._client: CodexAppServerClient | None = None
|
|
129
177
|
self._temporary: tempfile.TemporaryDirectory | None = None
|
|
130
178
|
self._opening: asyncio.Task | None = None
|
|
131
179
|
self._cleaning: asyncio.Task | None = None
|
|
180
|
+
self._process_started = asyncio.Event()
|
|
132
181
|
self._active: asyncio.Task | None = None
|
|
133
182
|
self._lock = asyncio.Lock()
|
|
134
183
|
self._closed = False
|
|
@@ -137,13 +186,22 @@ class NativeCodexSideChat:
|
|
|
137
186
|
def closed(self) -> bool:
|
|
138
187
|
return self._closed
|
|
139
188
|
|
|
189
|
+
async def _handle_server_request(self, request_id, method, params):
|
|
190
|
+
# The callback can present approvals through the existing UI, but only
|
|
191
|
+
# for this exact child. Never route a parent's inherited tool request.
|
|
192
|
+
if (self.server_request_handler is not None and not self._closed
|
|
193
|
+
and self._active is not None and self.thread_id is not None
|
|
194
|
+
and params.get("threadId") == self.thread_id):
|
|
195
|
+
return await self.server_request_handler(request_id, method, params)
|
|
196
|
+
return await decline_server_request(request_id, method, params)
|
|
197
|
+
|
|
140
198
|
async def _open(self):
|
|
141
199
|
self._temporary = tempfile.TemporaryDirectory(prefix="agentsdock-side-chat-")
|
|
142
200
|
temporary = self._temporary.name
|
|
143
201
|
await _verify_protocol(self.executable, temporary, self.env)
|
|
144
202
|
if self._closed:
|
|
145
203
|
raise SideQuestionError(409, "Side chat was closed; open a new side chat")
|
|
146
|
-
config =
|
|
204
|
+
config = side_chat_config(self.fork_overrides.get("config"))
|
|
147
205
|
config.update(self.provider_config)
|
|
148
206
|
# Preserve Codex's auth/runtime location. Overriding sqlite_home while
|
|
149
207
|
# retaining the user's history root can trigger a complete reindex.
|
|
@@ -167,44 +225,63 @@ class NativeCodexSideChat:
|
|
|
167
225
|
client_options["before_start"] = prepare_catalog
|
|
168
226
|
args = config_args(config)
|
|
169
227
|
self._client = CodexAppServerClient(
|
|
170
|
-
self.executable, cwd=temporary, env_factory=lambda: self.env,
|
|
171
|
-
app_server_args=args,
|
|
228
|
+
self.executable, cwd=self.cwd or temporary, env_factory=lambda: self.env,
|
|
229
|
+
app_server_args=args,
|
|
172
230
|
process_stream_limit=MAX_OUTPUT_BYTES, notification_queue_limit=512,
|
|
173
231
|
sensitive_values=self.sensitive_values,
|
|
232
|
+
protected_env_keys=self.protected_env_keys,
|
|
233
|
+
server_request_handler=self._handle_server_request,
|
|
234
|
+
on_process_started=lambda _pid, _group: self._process_started.set(),
|
|
174
235
|
**client_options,
|
|
175
236
|
)
|
|
176
237
|
await self._client.start()
|
|
177
238
|
if self._closed:
|
|
178
239
|
raise SideQuestionError(409, "Side chat was closed; open a new side chat")
|
|
179
|
-
|
|
180
|
-
settings = effective.get("config") if isinstance(effective, dict) else None
|
|
181
|
-
if not isinstance(settings, dict):
|
|
182
|
-
raise SideQuestionError(503, "Codex could not confirm isolated configuration")
|
|
183
|
-
servers = settings.get("mcp_servers", {})
|
|
184
|
-
if not isinstance(servers, dict) or any(not isinstance(value, dict) for value in servers.values()):
|
|
185
|
-
raise SideQuestionError(503, "Codex could not confirm isolated integrations")
|
|
186
|
-
# Empty maps merge with inherited config; explicitly disable every
|
|
187
|
-
# server. Nested keys preserve integration names containing dots.
|
|
188
|
-
config["mcp_servers"] = {name: {"enabled": False} for name in servers}
|
|
240
|
+
inherited_instructions = self.fork_overrides.get("developerInstructions") or ""
|
|
189
241
|
params = {
|
|
190
|
-
|
|
191
|
-
"
|
|
192
|
-
"
|
|
242
|
+
**self.fork_overrides,
|
|
243
|
+
"ephemeral": not self.durable, "excludeTurns": True,
|
|
244
|
+
"developerInstructions": "\n\n".join(value for value in (inherited_instructions, SIDE_INSTRUCTIONS) if value),
|
|
193
245
|
"config": config,
|
|
194
246
|
}
|
|
247
|
+
if self.cwd:
|
|
248
|
+
params["cwd"] = self.cwd
|
|
195
249
|
if self.model:
|
|
196
250
|
params["model"] = self.model
|
|
197
251
|
if self.provider_config:
|
|
198
252
|
params["modelProvider"] = self.provider_config["model_provider"]
|
|
199
253
|
if self.parent_rollout_path:
|
|
200
254
|
params["path"] = self.parent_rollout_path
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
255
|
+
if self.durable:
|
|
256
|
+
# Preserve full native context across server restart without ever
|
|
257
|
+
# allowing an inherited parent goal to continue in the side chat.
|
|
258
|
+
params["deferGoalContinuation"] = True
|
|
259
|
+
if self.resume_state is not None:
|
|
260
|
+
saved_id = self.resume_state.get("thread_id")
|
|
261
|
+
if not self.durable or not isinstance(saved_id, str) or not saved_id or saved_id == self.parent_thread_id:
|
|
262
|
+
raise SideQuestionError(410, "Saved Codex side conversation is unavailable; clear Side chat")
|
|
263
|
+
params.pop("ephemeral", None)
|
|
264
|
+
params.pop("path", None)
|
|
265
|
+
if self.resume_state.get("path"):
|
|
266
|
+
params["path"] = self.resume_state["path"]
|
|
267
|
+
self.thread_id = await self._client.resume_thread(saved_id, params)
|
|
268
|
+
else:
|
|
269
|
+
# Native ephemeral forks cannot carry deferGoalContinuation.
|
|
270
|
+
self.thread_id = await self._client.fork_thread(self.parent_thread_id, params)
|
|
271
|
+
if self._closed:
|
|
272
|
+
# A fork reply can race Stop. Do not let the following read lazily
|
|
273
|
+
# restart a private transport that cleanup has already closed.
|
|
274
|
+
raise SideQuestionError(409, "Side chat was closed; open a new side chat")
|
|
204
275
|
if self.thread_id == self.parent_thread_id:
|
|
205
276
|
raise SideQuestionError(503, "Codex did not create a separate side chat")
|
|
206
277
|
metadata = await self._client.read_thread(self.thread_id, include_turns=False)
|
|
207
|
-
if
|
|
278
|
+
if self.durable:
|
|
279
|
+
if metadata.get("ephemeral") is not False or not isinstance(metadata.get("path"), str):
|
|
280
|
+
raise SideQuestionError(503, "Codex did not save the native side conversation")
|
|
281
|
+
if self.persist_state is not None:
|
|
282
|
+
await self.persist_state({"thread_id": self.thread_id, "path": metadata["path"]})
|
|
283
|
+
await self._client.clear_thread_goal(self.thread_id)
|
|
284
|
+
elif metadata.get("ephemeral") is not True or metadata.get("path") is not None:
|
|
208
285
|
raise SideQuestionError(503, "Codex did not confirm an ephemeral side chat")
|
|
209
286
|
|
|
210
287
|
async def ask(self, question: str) -> str:
|
|
@@ -225,7 +302,7 @@ class NativeCodexSideChat:
|
|
|
225
302
|
raise SideQuestionError(409, "Side chat was closed; open a new side chat")
|
|
226
303
|
turn = await self._client.start_turn(
|
|
227
304
|
self.thread_id, [{"type": "text", "text": question}],
|
|
228
|
-
overrides={**self.
|
|
305
|
+
overrides={**self.turn_overrides, **self.provider_turn_overrides},
|
|
229
306
|
)
|
|
230
307
|
answers: dict[str, str] = {}
|
|
231
308
|
while True:
|
|
@@ -265,15 +342,31 @@ class NativeCodexSideChat:
|
|
|
265
342
|
active.cancel()
|
|
266
343
|
if self._cleaning is None:
|
|
267
344
|
async def cleanup():
|
|
268
|
-
|
|
269
|
-
with suppress(BaseException):
|
|
270
|
-
await self._opening
|
|
345
|
+
client_closed = False
|
|
271
346
|
try:
|
|
272
|
-
if self.
|
|
347
|
+
if self._opening is not None and not self._opening.done():
|
|
348
|
+
# Keep ownership while subprocess creation is in flight.
|
|
349
|
+
# Once its handle exists, Stop can close this private
|
|
350
|
+
# process without waiting for initialize/fork replies.
|
|
351
|
+
started = asyncio.create_task(self._process_started.wait())
|
|
352
|
+
try:
|
|
353
|
+
await asyncio.wait({self._opening, started}, return_when=asyncio.FIRST_COMPLETED)
|
|
354
|
+
finally:
|
|
355
|
+
started.cancel()
|
|
356
|
+
await asyncio.gather(started, return_exceptions=True)
|
|
357
|
+
if self._process_started.is_set() and self._client is not None:
|
|
273
358
|
await self._client.close()
|
|
359
|
+
client_closed = True
|
|
360
|
+
if self._opening is not None:
|
|
361
|
+
with suppress(BaseException):
|
|
362
|
+
await self._opening
|
|
274
363
|
finally:
|
|
275
|
-
|
|
276
|
-
self.
|
|
364
|
+
try:
|
|
365
|
+
if self._client is not None and not client_closed:
|
|
366
|
+
await self._client.close()
|
|
367
|
+
finally:
|
|
368
|
+
if self._temporary is not None:
|
|
369
|
+
self._temporary.cleanup()
|
|
277
370
|
self._cleaning = asyncio.create_task(cleanup())
|
|
278
371
|
cancelled = False
|
|
279
372
|
while not self._cleaning.done():
|
|
@@ -291,10 +384,14 @@ class NativeCodexSideChat:
|
|
|
291
384
|
|
|
292
385
|
async def answer_side_question(question: str, *, parent_thread_id: str, executable: str,
|
|
293
386
|
model: str | None, env: dict,
|
|
294
|
-
parent_rollout_path: str | None = None
|
|
387
|
+
parent_rollout_path: str | None = None,
|
|
388
|
+
cwd: str | None = None, fork_overrides: dict | None = None,
|
|
389
|
+
turn_overrides: dict | None = None, server_request_handler=None) -> str:
|
|
295
390
|
"""Single-question convenience wrapper; follow-ups use NativeCodexSideChat."""
|
|
296
391
|
chat = NativeCodexSideChat(parent_thread_id, executable=executable, model=model,
|
|
297
|
-
env=env, parent_rollout_path=parent_rollout_path
|
|
392
|
+
env=env, parent_rollout_path=parent_rollout_path,
|
|
393
|
+
cwd=cwd, fork_overrides=fork_overrides, turn_overrides=turn_overrides,
|
|
394
|
+
server_request_handler=server_request_handler)
|
|
298
395
|
try:
|
|
299
396
|
return await chat.ask(question)
|
|
300
397
|
finally:
|
|
@@ -106,6 +106,32 @@ def canonical_cursor_call_id(value: Any) -> str:
|
|
|
106
106
|
return value
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def _cursor_tool_call_id(
|
|
110
|
+
event: dict[str, Any],
|
|
111
|
+
body: dict[str, Any],
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Return Cursor's tool correlation key with one narrow compatibility fallback.
|
|
114
|
+
|
|
115
|
+
Cursor documents the correlation key at the top-level ``call_id`` field,
|
|
116
|
+
but current tool payloads also repeat it as ``args.toolCallId``. Some CLI
|
|
117
|
+
events have emitted an invalid top-level value while retaining the valid
|
|
118
|
+
nested copy. Prefer the documented field and consult only that exact
|
|
119
|
+
nested location when top-level validation fails; all candidates still use
|
|
120
|
+
the same bounded, control-free validation.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
return canonical_cursor_call_id(event.get("call_id"))
|
|
125
|
+
except CursorEventParseError as top_level_error:
|
|
126
|
+
args = body.get("args")
|
|
127
|
+
if not isinstance(args, dict) or "toolCallId" not in args:
|
|
128
|
+
raise
|
|
129
|
+
try:
|
|
130
|
+
return canonical_cursor_call_id(args.get("toolCallId"))
|
|
131
|
+
except CursorEventParseError:
|
|
132
|
+
raise top_level_error from None
|
|
133
|
+
|
|
134
|
+
|
|
109
135
|
def _cursor_lifecycle_session_id(event: dict[str, Any], event_name: str) -> str:
|
|
110
136
|
session_id = canonical_cursor_session_id(event.get("session_id"))
|
|
111
137
|
if not session_id:
|
|
@@ -334,7 +360,7 @@ def normalize_cursor_stream_event(raw_line: str) -> dict[str, Any] | None:
|
|
|
334
360
|
if not isinstance(tool_call, dict):
|
|
335
361
|
raise CursorEventParseError("tool_call event missing tool_call payload")
|
|
336
362
|
name, body = _tool_call_name_and_body(tool_call)
|
|
337
|
-
call_id =
|
|
363
|
+
call_id = _cursor_tool_call_id(event, body)
|
|
338
364
|
if subtype == "started":
|
|
339
365
|
return {
|
|
340
366
|
"kind": "tool_started",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Cursor-issued keys, not OpenAI-compatible endpoints or a machine login.
|
|
2
|
+
|
|
3
|
+
Cursor exchanges API keys for tokens and normally persists those tokens in the
|
|
4
|
+
user's credential store. Use its in-memory store for every key-bound process;
|
|
5
|
+
an inherited auth token must never take precedence over the selected API key.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
from cursor_agent_client import parse_cursor_models_list
|
|
14
|
+
from side_questions import run_isolated_command, SideQuestionError
|
|
15
|
+
|
|
16
|
+
ENDPOINT = "https://api2.cursor.sh"
|
|
17
|
+
MIN_VERSION = (2026, 9, 26) # First build verified with memory credential isolation.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def overrides(key: str) -> dict[str, str]:
|
|
21
|
+
return {
|
|
22
|
+
"CURSOR_API_KEY": key, "CURSOR_AUTH_TOKEN": "",
|
|
23
|
+
"AGENT_CLI_CREDENTIAL_STORE": "memory",
|
|
24
|
+
"CURSOR_API_ENDPOINT": ENDPOINT, "CURSOR_AGENT_CLI_LOCAL_MODE": "false",
|
|
25
|
+
"DIRENV_DISABLE": "1",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def require_isolation(executable: str | None, env: dict) -> None:
|
|
30
|
+
if not executable:
|
|
31
|
+
raise HTTPException(409, "Install Cursor CLI to use a Cursor API key.")
|
|
32
|
+
try:
|
|
33
|
+
with tempfile.TemporaryDirectory(prefix="agentsdock-cursor-version-") as cwd:
|
|
34
|
+
output = await run_isolated_command([executable, "--version"], prompt="", cwd=cwd,
|
|
35
|
+
env={**env, **overrides("")}, timeout=5)
|
|
36
|
+
match = re.search(r"\b(20\d\d)\.(\d{2})\.(\d{2})\b", output)
|
|
37
|
+
if not match or tuple(map(int, match.groups())) < MIN_VERSION:
|
|
38
|
+
raise ValueError("unsupported build")
|
|
39
|
+
except (SideQuestionError, ValueError):
|
|
40
|
+
raise HTTPException(409, "Update Cursor CLI to 2026.09.26 or later to isolate API keys from CLI login.") from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def catalog(selected: dict, *, executable: str | None, env: dict) -> dict:
|
|
44
|
+
try:
|
|
45
|
+
await require_isolation(executable, env)
|
|
46
|
+
except HTTPException:
|
|
47
|
+
return {"models": [], "discovery_status": "unavailable", "cli_update_required": True}
|
|
48
|
+
try:
|
|
49
|
+
# Read-only authenticated request; no conversation, model inference,
|
|
50
|
+
# project tools, or native credential writes. Never reflect CLI stderr.
|
|
51
|
+
with tempfile.TemporaryDirectory(prefix="agentsdock-cursor-key-") as cwd:
|
|
52
|
+
output = await run_isolated_command([executable, "--list-models"], prompt="", cwd=cwd,
|
|
53
|
+
env={**env, **overrides(selected["api_key"])}, timeout=20)
|
|
54
|
+
models = [{"value": item["id"], "label": item["label"]}
|
|
55
|
+
for item in parse_cursor_models_list(output)
|
|
56
|
+
if re.fullmatch(r"[\x21-\x7e]{1,256}", item["id"])
|
|
57
|
+
and selected["api_key"] not in item["id"] + item["label"]]
|
|
58
|
+
if not models:
|
|
59
|
+
return {"models": [], "discovery_status": "unavailable"}
|
|
60
|
+
return {"models": models[:256], "discovery_status": "ready"}
|
|
61
|
+
except SideQuestionError:
|
|
62
|
+
# Offline and invalid keys can look identical in CLI output. Do not
|
|
63
|
+
# misclassify an outage as a logout or return key-bearing stderr.
|
|
64
|
+
return {"models": [], "discovery_status": "unavailable"}
|