@agentsdock/server 1.0.7 → 1.0.8-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +392 -60
- package/server/codex_app_server.py +12 -0
- package/server/codex_history_repair.py +79 -14
- package/server/codex_provider.py +159 -76
- package/server/cursor_api_key.py +64 -0
- package/server/install.sh +120 -7
- package/server/opencode_agent_client.py +32 -0
- package/server/provider_connections.py +665 -0
- package/server/pyproject.toml +1 -0
- package/server/title_generation.py +4 -1
- package/server/uv.lock +3 -1
package/server/agent_server.py
CHANGED
|
@@ -77,6 +77,8 @@ import chat_mailbox
|
|
|
77
77
|
import workspace_git
|
|
78
78
|
import codex_auth
|
|
79
79
|
import codex_provider
|
|
80
|
+
import provider_connections
|
|
81
|
+
import cursor_api_key
|
|
80
82
|
import server_instances
|
|
81
83
|
import local_session_ownership
|
|
82
84
|
import cursor_history
|
|
@@ -406,6 +408,7 @@ SERVER_RESTART_STATUS_FILE = SERVER_ADMIN_ROOT / "server-restart.json"
|
|
|
406
408
|
TEAM_HUB_HOST_CONTROL_STATUS_FILE = SERVER_ADMIN_ROOT / "team-hub-host.json"
|
|
407
409
|
CODEX_SETTINGS_FILE = SERVER_ADMIN_ROOT / "codex-settings.json"
|
|
408
410
|
CODEX_PROVIDER_STORE = codex_provider.ProviderStore(SERVER_ADMIN_ROOT / "codex-provider")
|
|
411
|
+
PROVIDER_CONNECTION_STORE = provider_connections.ConnectionStore(SERVER_ADMIN_ROOT / "provider-connections")
|
|
409
412
|
ABANDONED_FORK_THREADS_FILE = SERVER_ADMIN_ROOT / "abandoned-fork-threads.json"
|
|
410
413
|
# Process-group ids of provider children this server spawned in their own
|
|
411
414
|
# session (``start_new_session=True``). A SIGKILL of the server cannot reach
|
|
@@ -4051,6 +4054,24 @@ def workspace_search_rank(path: str, query: str) -> tuple[int, int, str]:
|
|
|
4051
4054
|
return (score, len(path), lower)
|
|
4052
4055
|
|
|
4053
4056
|
|
|
4057
|
+
def workspace_search_private_roots(root: Path) -> tuple[str, ...]:
|
|
4058
|
+
"""Don't discover private macOS data incidentally from a broad workspace.
|
|
4059
|
+
|
|
4060
|
+
These are locations under the actual user's home, not directory names to
|
|
4061
|
+
suppress in every project. A workspace at/inside one of these locations is
|
|
4062
|
+
an explicit scope; direct file opens and directory browsing are unchanged.
|
|
4063
|
+
Only resolve HOME, not the private locations (which may themselves prompt).
|
|
4064
|
+
The directory walker still refuses symlinks, including aliases into them.
|
|
4065
|
+
"""
|
|
4066
|
+
if sys.platform != "darwin":
|
|
4067
|
+
return ()
|
|
4068
|
+
home = Path(str(Path.home().resolve()).casefold())
|
|
4069
|
+
scope = Path(str(root).casefold())
|
|
4070
|
+
locations = ("Library", "Music", "Pictures", "Movies", "Desktop", "Documents", "Downloads", ".Trash")
|
|
4071
|
+
return tuple(str(private.relative_to(scope)) for name in locations
|
|
4072
|
+
if (private := home / name.casefold()) != scope and private.is_relative_to(scope))
|
|
4073
|
+
|
|
4074
|
+
|
|
4054
4075
|
def search_git_workspace_files(
|
|
4055
4076
|
sess: dict[str, Any],
|
|
4056
4077
|
root: Path,
|
|
@@ -4152,7 +4173,10 @@ def search_git_workspace_files(
|
|
|
4152
4173
|
def search_workspace_files_sync(session_id: str, query: str, limit: int) -> dict[str, Any]:
|
|
4153
4174
|
sess, root = session_workspace_root(session_id)
|
|
4154
4175
|
clean_query = str(query or "").strip().casefold()
|
|
4155
|
-
|
|
4176
|
+
private_roots = workspace_search_private_roots(root)
|
|
4177
|
+
# ls-files --others can enumerate excluded trees before we filter its
|
|
4178
|
+
# output. Use the guarded walker for broad scopes, even for a Git HOME.
|
|
4179
|
+
if clean_query and not private_roots:
|
|
4156
4180
|
indexed = search_git_workspace_files(sess, root, clean_query, limit)
|
|
4157
4181
|
# Git is a fast candidate index, not an authoritative view of the
|
|
4158
4182
|
# workspace: ``--exclude-standard`` intentionally omits ignored files
|
|
@@ -4181,6 +4205,9 @@ def search_workspace_files_sync(session_id: str, query: str, limit: int) -> dict
|
|
|
4181
4205
|
try:
|
|
4182
4206
|
names = sorted(os.listdir(directory_fd), key=lambda value: (value.casefold(), value))
|
|
4183
4207
|
for name in names:
|
|
4208
|
+
path = f"{directory}/{name}" if directory else name
|
|
4209
|
+
if path.casefold() in private_roots:
|
|
4210
|
+
continue
|
|
4184
4211
|
try:
|
|
4185
4212
|
item_stat = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
4186
4213
|
except OSError:
|
|
@@ -4194,7 +4221,6 @@ def search_workspace_files_sync(session_id: str, query: str, limit: int) -> dict
|
|
|
4194
4221
|
truncated = True
|
|
4195
4222
|
queue.clear()
|
|
4196
4223
|
break
|
|
4197
|
-
path = f"{directory}/{name}" if directory else name
|
|
4198
4224
|
if stat.S_ISDIR(item_stat.st_mode):
|
|
4199
4225
|
if name not in WORKSPACE_SEARCH_IGNORED_DIRECTORIES and not stat.S_ISLNK(item_stat.st_mode):
|
|
4200
4226
|
queue.append(path)
|
|
@@ -6286,6 +6312,7 @@ class CreateSessionRequest(BaseModel):
|
|
|
6286
6312
|
cwd: str | None = None
|
|
6287
6313
|
backend: str | None = None
|
|
6288
6314
|
codex_provider: Literal["default", "custom"] | None = None
|
|
6315
|
+
provider_connection: Literal["default", "custom"] | None = None
|
|
6289
6316
|
model: str | None = None
|
|
6290
6317
|
effort: str | None = None
|
|
6291
6318
|
subagent_limit: int | None = Field(default=None, strict=True, ge=1)
|
|
@@ -6359,6 +6386,7 @@ class UpdateSessionRequest(BaseModel):
|
|
|
6359
6386
|
cwd: str | None = None
|
|
6360
6387
|
backend: str | None = None
|
|
6361
6388
|
codex_provider: Literal["default", "custom"] | None = None
|
|
6389
|
+
provider_connection: Literal["default", "custom"] | None = None
|
|
6362
6390
|
model: str | None = None
|
|
6363
6391
|
effort: str | None = None
|
|
6364
6392
|
subagent_limit: int | None = Field(default=None, strict=True, ge=1)
|
|
@@ -6386,6 +6414,7 @@ SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
|
|
|
6386
6414
|
"cwd",
|
|
6387
6415
|
"backend",
|
|
6388
6416
|
"codex_provider",
|
|
6417
|
+
"provider_connection",
|
|
6389
6418
|
"model",
|
|
6390
6419
|
"effort",
|
|
6391
6420
|
"system_prompt",
|
|
@@ -7177,6 +7206,15 @@ def preview_session_runtime_update(
|
|
|
7177
7206
|
if provider_changed and session_backend_locked(sess):
|
|
7178
7207
|
raise HTTPException(409, "Codex provider is locked after the chat starts; create a new chat to use another provider.")
|
|
7179
7208
|
|
|
7209
|
+
current_connection = sess.get("provider_connection") or "default"
|
|
7210
|
+
connection = (patch.get("provider_connection") or "default") if "provider_connection" in patch else "default" if backend_changed else current_connection
|
|
7211
|
+
if connection not in {"default", "custom"} or connection == "custom" and prospective_backend not in {BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR}:
|
|
7212
|
+
raise HTTPException(400, "This backend does not support that API connection.")
|
|
7213
|
+
connection_changed = current_connection != connection
|
|
7214
|
+
if connection_changed and session_backend_locked(sess):
|
|
7215
|
+
raise HTTPException(409, "API connection is locked after the chat starts; create a new chat to change it.")
|
|
7216
|
+
provider_changed = provider_changed or connection_changed
|
|
7217
|
+
|
|
7180
7218
|
prospective_model = (
|
|
7181
7219
|
str(patch.get("model") or "").strip() or None
|
|
7182
7220
|
if "model" in patch
|
|
@@ -7203,6 +7241,13 @@ def preview_session_runtime_update(
|
|
|
7203
7241
|
preview.pop("codex_provider_revision", None)
|
|
7204
7242
|
preview["model"] = prospective_model
|
|
7205
7243
|
preview["effort"] = normalized_effort
|
|
7244
|
+
preview["provider_connection"] = connection
|
|
7245
|
+
if connection_changed or connection == "default":
|
|
7246
|
+
preview.pop("provider_connection_revision", None)
|
|
7247
|
+
if connection == "custom":
|
|
7248
|
+
selected_connection = PROVIDER_CONNECTION_STORE.bind(preview)
|
|
7249
|
+
preview["provider_connection_revision"] = selected_connection["credential_id"]
|
|
7250
|
+
preview["model"] = selected_connection.get("model")
|
|
7206
7251
|
if prospective_provider == "custom" and {"backend", "codex_provider", "model", "effort"}.intersection(patch):
|
|
7207
7252
|
if CODEX_TRANSPORT == CODEX_TRANSPORT_EXEC:
|
|
7208
7253
|
raise HTTPException(409, "Custom endpoints require native Codex app-server transport.")
|
|
@@ -7224,6 +7269,23 @@ def preview_session_runtime_update(
|
|
|
7224
7269
|
return preview
|
|
7225
7270
|
|
|
7226
7271
|
|
|
7272
|
+
def effective_codex_approval_policy(sess: dict[str, Any]) -> str:
|
|
7273
|
+
policy = str(sess.get("codex_approval_policy") or CODEX_DEFAULT_APPROVAL_POLICY)
|
|
7274
|
+
return policy if policy in CODEX_APPROVAL_POLICIES else CODEX_DEFAULT_APPROVAL_POLICY
|
|
7275
|
+
|
|
7276
|
+
|
|
7277
|
+
def active_codex_approval_policy(session_id: str, thread_id: str) -> str:
|
|
7278
|
+
"""Use the current native turn's policy, not next-turn settings edits."""
|
|
7279
|
+
active = ACTIVE.get(session_id) or {}
|
|
7280
|
+
captured = str(active.get("codex_approval_policy") or "")
|
|
7281
|
+
if (
|
|
7282
|
+
str(active.get("provider_thread_id") or "") == thread_id
|
|
7283
|
+
and captured in CODEX_APPROVAL_POLICIES
|
|
7284
|
+
):
|
|
7285
|
+
return captured
|
|
7286
|
+
return effective_codex_approval_policy(STORE.sessions.get(session_id) or {})
|
|
7287
|
+
|
|
7288
|
+
|
|
7227
7289
|
def effective_claude_permission_mode(sess: dict[str, Any]) -> str:
|
|
7228
7290
|
"""Return one canonical SDK permission mode for persisted/legacy chats."""
|
|
7229
7291
|
|
|
@@ -10484,8 +10546,13 @@ class SessionStore:
|
|
|
10484
10546
|
runtime_source.update({name: parent.get(name) for name in (
|
|
10485
10547
|
"codex_provider", "codex_provider_binding", "codex_provider_revision",
|
|
10486
10548
|
)})
|
|
10549
|
+
if initializing_fork and parent_id and req.provider_connection == "custom":
|
|
10550
|
+
parent = self.sessions.get(parent_id) or {}
|
|
10551
|
+
runtime_source.update({name: parent.get(name) for name in ("provider_connection", "provider_connection_revision")})
|
|
10552
|
+
if req.provider_connection == "custom" and not initializing_fork and any((req.provider_session_id, req.session_id, req.claude_session_id, req.opencode_session_id)):
|
|
10553
|
+
raise HTTPException(409, "Import native conversations with their native login. Create a new chat for a custom API.")
|
|
10487
10554
|
runtime = preview_session_runtime_update(runtime_source, {
|
|
10488
|
-
"codex_provider": req.codex_provider, "model": req.model, "effort": req.effort,
|
|
10555
|
+
"codex_provider": req.codex_provider, "provider_connection": req.provider_connection, "model": req.model, "effort": req.effort,
|
|
10489
10556
|
})
|
|
10490
10557
|
model, effort = runtime["model"], runtime["effort"]
|
|
10491
10558
|
session_cwd = req.cwd or DEFAULT_CWD
|
|
@@ -10582,6 +10649,8 @@ class SessionStore:
|
|
|
10582
10649
|
"codex_provider": runtime["codex_provider"],
|
|
10583
10650
|
"codex_provider_binding": runtime.get("codex_provider_binding"),
|
|
10584
10651
|
"codex_provider_revision": runtime.get("codex_provider_revision"),
|
|
10652
|
+
"provider_connection": runtime["provider_connection"],
|
|
10653
|
+
"provider_connection_revision": runtime.get("provider_connection_revision"),
|
|
10585
10654
|
"model": model,
|
|
10586
10655
|
"effort": effort,
|
|
10587
10656
|
"system_prompt": clean_session_system_prompt(req.system_prompt),
|
|
@@ -10702,7 +10771,7 @@ class SessionStore:
|
|
|
10702
10771
|
raise HTTPException(status_code=404, detail="session not found")
|
|
10703
10772
|
if "subagent_limit" in patch:
|
|
10704
10773
|
validate_session_subagent_limit({**sess, "backend": patch.get("backend") or sess.get("backend")}, patch["subagent_limit"])
|
|
10705
|
-
previous_provider_runtime = dict(sess) if {"codex_provider", "subagent_limit"}.intersection(patch) else None
|
|
10774
|
+
previous_provider_runtime = dict(sess) if {"codex_provider", "provider_connection", "subagent_limit"}.intersection(patch) else None
|
|
10706
10775
|
missing_policy = object()
|
|
10707
10776
|
previous_provider_jobs_access = sess.get(
|
|
10708
10777
|
"provider_jobs_access",
|
|
@@ -10715,6 +10784,7 @@ class SessionStore:
|
|
|
10715
10784
|
).lower()
|
|
10716
10785
|
backend_changed = prospective_backend != current_backend
|
|
10717
10786
|
provider_changed = runtime_preview["codex_provider"] != codex_provider.session_choice(sess.get("codex_provider"))
|
|
10787
|
+
provider_changed = provider_changed or runtime_preview["provider_connection"] != (sess.get("provider_connection") or "default")
|
|
10718
10788
|
previous_opencode_permission_mode = (
|
|
10719
10789
|
effective_opencode_permission_mode(sess)
|
|
10720
10790
|
)
|
|
@@ -10783,6 +10853,9 @@ class SessionStore:
|
|
|
10783
10853
|
sess["codex_provider"] = runtime_preview["codex_provider"]
|
|
10784
10854
|
sess["codex_provider_binding"] = runtime_preview.get("codex_provider_binding")
|
|
10785
10855
|
sess["codex_provider_revision"] = runtime_preview.get("codex_provider_revision")
|
|
10856
|
+
if "provider_connection" in patch or backend_changed:
|
|
10857
|
+
sess["provider_connection"] = runtime_preview["provider_connection"]
|
|
10858
|
+
sess["provider_connection_revision"] = runtime_preview.get("provider_connection_revision")
|
|
10786
10859
|
if provider_changed:
|
|
10787
10860
|
sess["model"] = runtime_preview["model"]
|
|
10788
10861
|
sess["effort"] = runtime_preview["effort"]
|
|
@@ -16846,6 +16919,9 @@ async def append_event(
|
|
|
16846
16919
|
event_type: str,
|
|
16847
16920
|
payload: dict[str, Any] | None = None,
|
|
16848
16921
|
) -> dict[str, Any]:
|
|
16922
|
+
connection_session = STORE.sessions.get(session_id) or {}
|
|
16923
|
+
if connection_session.get("provider_connection") == "custom" and payload:
|
|
16924
|
+
payload = PROVIDER_CONNECTION_STORE.redact(connection_session, payload)
|
|
16849
16925
|
def discarded_event() -> dict[str, Any]:
|
|
16850
16926
|
return {
|
|
16851
16927
|
"seq": 0,
|
|
@@ -20650,7 +20726,7 @@ def provider_tool_active_matches(
|
|
|
20650
20726
|
expected_transport = (
|
|
20651
20727
|
CODEX_TRANSPORT_APP_SERVER
|
|
20652
20728
|
if backend == BACKEND_CODEX
|
|
20653
|
-
else "exec" if backend
|
|
20729
|
+
else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE}
|
|
20654
20730
|
else CLAUDE_TRANSPORT_AGENT_SDK
|
|
20655
20731
|
)
|
|
20656
20732
|
matches = bool(
|
|
@@ -20675,11 +20751,13 @@ def provider_tool_active_matches(
|
|
|
20675
20751
|
and str(active.get("provider_turn_id") or "") == provider_turn_id
|
|
20676
20752
|
and active.get("provider_turn_ready") is True
|
|
20677
20753
|
)
|
|
20678
|
-
elif backend
|
|
20754
|
+
elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE}:
|
|
20679
20755
|
proc = active.get("proc")
|
|
20680
20756
|
matches = bool(
|
|
20681
20757
|
matches and cursor_owner_token
|
|
20682
|
-
|
|
20758
|
+
# Historical argument name; both exec transports use an exact,
|
|
20759
|
+
# backend-specific owner, never an interchangeable session token.
|
|
20760
|
+
and hmac.compare_digest(str(active.get(f"{backend}_mcp_owner_token") or ""), cursor_owner_token)
|
|
20683
20761
|
and proc is not None and proc.returncode is None
|
|
20684
20762
|
and active.get("provider_turn_ready") is True
|
|
20685
20763
|
and isinstance(ready, asyncio.Event) and ready.is_set()
|
|
@@ -20746,7 +20824,7 @@ async def provider_tool_capability_snapshot(
|
|
|
20746
20824
|
and codex_native_mailbox_owner_matches(session_id, run_id, provider_thread_id, provider_turn_id))
|
|
20747
20825
|
):
|
|
20748
20826
|
raise ProviderToolError("provider tool turn is stale")
|
|
20749
|
-
elif backend
|
|
20827
|
+
elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE}:
|
|
20750
20828
|
raise ProviderToolError("provider tool backend is unsupported")
|
|
20751
20829
|
|
|
20752
20830
|
async with ACTIVE_LOCK:
|
|
@@ -38394,17 +38472,22 @@ def cross_chat_supported_target_backends() -> list[str]:
|
|
|
38394
38472
|
and claude_sdk_dependency_available()
|
|
38395
38473
|
):
|
|
38396
38474
|
supported.append(BACKEND_CLAUDE)
|
|
38397
|
-
#
|
|
38398
|
-
#
|
|
38399
|
-
#
|
|
38400
|
-
#
|
|
38401
|
-
|
|
38402
|
-
|
|
38403
|
-
|
|
38404
|
-
|
|
38405
|
-
)
|
|
38406
|
-
|
|
38407
|
-
|
|
38475
|
+
# Native login and an explicitly verified API connection are independent.
|
|
38476
|
+
# Only cached diagnostics/private metadata are read here; health must not
|
|
38477
|
+
# launch CLIs or authenticate remotely. Each target's actual turn admission
|
|
38478
|
+
# still checks its own binding, CLI compatibility and selected login mode.
|
|
38479
|
+
for backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
38480
|
+
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
38481
|
+
diagnostic = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
|
|
38482
|
+
ready = diagnostic.get("status") == "ready"
|
|
38483
|
+
if not ready and diagnostic.get("installed") is True:
|
|
38484
|
+
try:
|
|
38485
|
+
connection = PROVIDER_CONNECTION_STORE.public(backend)
|
|
38486
|
+
ready = connection.get("configured") is True and connection.get("last_result") == "verified"
|
|
38487
|
+
except HTTPException:
|
|
38488
|
+
pass # Unreadable/unverified API settings never grant readiness.
|
|
38489
|
+
if ready:
|
|
38490
|
+
supported.append(backend)
|
|
38408
38491
|
return supported
|
|
38409
38492
|
|
|
38410
38493
|
|
|
@@ -38427,12 +38510,12 @@ def cross_chat_handoffs_capability() -> dict[str, Any]:
|
|
|
38427
38510
|
else:
|
|
38428
38511
|
message = (
|
|
38429
38512
|
"Cross-chat handoffs require native Codex app-server, Claude "
|
|
38430
|
-
"Agent SDK, or a
|
|
38513
|
+
"Agent SDK, or a ready Cursor/OpenCode CLI target "
|
|
38431
38514
|
"transport."
|
|
38432
38515
|
)
|
|
38433
38516
|
action = (
|
|
38434
38517
|
"Enable Codex app-server, install and enable Claude Agent SDK, "
|
|
38435
|
-
"or install and authenticate a compatible Cursor CLI."
|
|
38518
|
+
"or install and authenticate a compatible Cursor/OpenCode CLI."
|
|
38436
38519
|
)
|
|
38437
38520
|
return {
|
|
38438
38521
|
"available": available,
|
|
@@ -38556,10 +38639,14 @@ def cross_chat_handoffs_capability() -> dict[str, Any]:
|
|
|
38556
38639
|
BACKEND_CODEX: CODEX_TRANSPORT_APP_SERVER,
|
|
38557
38640
|
BACKEND_CLAUDE: CLAUDE_TRANSPORT_AGENT_SDK,
|
|
38558
38641
|
BACKEND_CURSOR: "headless-stream-json",
|
|
38642
|
+
BACKEND_OPENCODE: "headless-stream-json",
|
|
38559
38643
|
},
|
|
38560
38644
|
}
|
|
38561
38645
|
|
|
38562
38646
|
|
|
38647
|
+
OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY = "opencode_cross_chat_v1"
|
|
38648
|
+
|
|
38649
|
+
|
|
38563
38650
|
def cross_chat_delivery_client_capabilities(target: dict[str, Any]) -> list[str]:
|
|
38564
38651
|
backend = str(target.get("backend") or DEFAULT_BACKEND).strip().lower()
|
|
38565
38652
|
if backend == BACKEND_CODEX:
|
|
@@ -38589,6 +38676,10 @@ def cross_chat_delivery_client_capabilities(target: dict[str, Any]) -> list[str]
|
|
|
38589
38676
|
# An empty immutable client-capability set selects that exact headless
|
|
38590
38677
|
# path; request/reply final answers are relayed by the server ledger.
|
|
38591
38678
|
return []
|
|
38679
|
+
if backend == BACKEND_OPENCODE:
|
|
38680
|
+
if not cross_chat_target_backend_supported(backend):
|
|
38681
|
+
raise HTTPException(status_code=409, detail="target OpenCode chat requires a ready OpenCode CLI for cross-chat delivery")
|
|
38682
|
+
return [OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY]
|
|
38592
38683
|
raise HTTPException(
|
|
38593
38684
|
status_code=409,
|
|
38594
38685
|
detail=f"target backend {backend!r} does not support cross-chat delivery",
|
|
@@ -38599,6 +38690,7 @@ CROSS_CHAT_DELIVERY_CAPABILITIES_BY_BACKEND: dict[str, frozenset[str]] = {
|
|
|
38599
38690
|
BACKEND_CODEX: frozenset({CODEX_INTERACTIVE_CLIENT_CAPABILITY}),
|
|
38600
38691
|
BACKEND_CLAUDE: frozenset({CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY}),
|
|
38601
38692
|
BACKEND_CURSOR: frozenset(),
|
|
38693
|
+
BACKEND_OPENCODE: frozenset({OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY}),
|
|
38602
38694
|
}
|
|
38603
38695
|
|
|
38604
38696
|
|
|
@@ -47972,7 +48064,8 @@ def active_generated_title_work_labels() -> list[str]:
|
|
|
47972
48064
|
|
|
47973
48065
|
def title_runtime_key(sess: dict[str, Any]) -> tuple:
|
|
47974
48066
|
return (sess.get("backend") or DEFAULT_BACKEND, session_provider_id(sess),
|
|
47975
|
-
sess.get("model"), sess.get("codex_provider"), sess.get("codex_provider_revision")
|
|
48067
|
+
sess.get("model"), sess.get("codex_provider"), sess.get("codex_provider_revision"),
|
|
48068
|
+
sess.get("provider_connection"), sess.get("provider_connection_revision"))
|
|
47976
48069
|
|
|
47977
48070
|
|
|
47978
48071
|
def generated_title_eligible(sess: dict[str, Any], *, allow_attempted: bool = False) -> bool:
|
|
@@ -48036,6 +48129,9 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
|
|
|
48036
48129
|
options["executable"] = resolve_cursor_executable()
|
|
48037
48130
|
if not options["executable"]:
|
|
48038
48131
|
return
|
|
48132
|
+
if snapshot.get("provider_connection") == "custom":
|
|
48133
|
+
await cursor_api_key.require_isolation(options["executable"], options["env"])
|
|
48134
|
+
options["env"].update(PROVIDER_CONNECTION_STORE.cursor_overrides(snapshot))
|
|
48039
48135
|
result = await title_generation.generate_title(
|
|
48040
48136
|
snapshot["backend"], snapshot["_title_seed"], reply, **options,
|
|
48041
48137
|
)
|
|
@@ -51422,6 +51518,14 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
51422
51518
|
# UI still needs the authoritative first-turn backend fence.
|
|
51423
51519
|
public["backend_locked"] = session_backend_locked(sess)
|
|
51424
51520
|
public["codex_provider"] = codex_provider.session_choice(sess.get("codex_provider"))
|
|
51521
|
+
connection = sess.get("provider_connection") or "default"
|
|
51522
|
+
# Keep legacy native summaries sparse, but retain a stored default as the
|
|
51523
|
+
# tombstone that clears a previously selected custom API in client caches.
|
|
51524
|
+
if not summary or "provider_connection" in sess:
|
|
51525
|
+
public["provider_connection"] = connection
|
|
51526
|
+
if connection == "custom":
|
|
51527
|
+
public["provider_connection_catalog"] = PROVIDER_CONNECTION_STORE.catalog(sess["backend"], session=sess, summary=summary)
|
|
51528
|
+
public.pop("provider_connection_revision", None)
|
|
51425
51529
|
if public["codex_provider"] == "custom":
|
|
51426
51530
|
public["codex_provider_catalog"] = CODEX_PROVIDER_STORE.catalog(
|
|
51427
51531
|
available=CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC, session=sess, summary=summary,
|
|
@@ -53678,6 +53782,7 @@ async def handle_codex_server_request(
|
|
|
53678
53782
|
*,
|
|
53679
53783
|
side_session_id: str | None = None,
|
|
53680
53784
|
side_owner_is_current: Any = None,
|
|
53785
|
+
side_approval_policy: str | None = None,
|
|
53681
53786
|
source_manager: CodexAppServerManager | None = None,
|
|
53682
53787
|
) -> dict[str, Any]:
|
|
53683
53788
|
"""Bridge one app-server prompt to the owning AgentsDock chat, fail closed."""
|
|
@@ -53712,6 +53817,16 @@ async def handle_codex_server_request(
|
|
|
53712
53817
|
):
|
|
53713
53818
|
pending = None
|
|
53714
53819
|
else:
|
|
53820
|
+
if method == "item/tool/requestUserInput":
|
|
53821
|
+
policy = (
|
|
53822
|
+
side_approval_policy or CODEX_DEFAULT_APPROVAL_POLICY
|
|
53823
|
+
if side_session_id is not None
|
|
53824
|
+
else active_codex_approval_policy(session_id, thread_id)
|
|
53825
|
+
)
|
|
53826
|
+
if policy == "never":
|
|
53827
|
+
# Native questions are optional input, not permission to
|
|
53828
|
+
# invent an answer. Skip before installing any UI waiter.
|
|
53829
|
+
return await decline_server_request(request_id, method, params)
|
|
53715
53830
|
manager = (None if side_session_id is not None else source_manager or
|
|
53716
53831
|
existing_codex_app_server_manager_for_thread(thread_id))
|
|
53717
53832
|
generation = manager.generation if manager is not None else 0
|
|
@@ -54112,15 +54227,20 @@ async def handle_claude_tool_permission(
|
|
|
54112
54227
|
|
|
54113
54228
|
# The SDK normally shadows can_use_tool in bypassPermissions mode,
|
|
54114
54229
|
# but the callback itself is not proof that approval is required.
|
|
54115
|
-
# Honor the
|
|
54230
|
+
# Honor the captured mode at the ownership fence so an
|
|
54116
54231
|
# unexpected callback cannot manufacture a desktop approval card.
|
|
54117
|
-
|
|
54232
|
+
permission_mode = active_claude_permission_mode(session_id, active)
|
|
54233
|
+
if (
|
|
54234
|
+
tool_name == "AskUserQuestion"
|
|
54235
|
+
and permission_mode in {"bypassPermissions", "dontAsk"}
|
|
54236
|
+
):
|
|
54237
|
+
return PermissionResultDeny(
|
|
54238
|
+
message="This question was skipped because this turn is configured not to prompt the user.",
|
|
54239
|
+
interrupt=False,
|
|
54240
|
+
)
|
|
54118
54241
|
if (
|
|
54119
54242
|
tool_name != "AskUserQuestion"
|
|
54120
|
-
and
|
|
54121
|
-
session_id,
|
|
54122
|
-
active,
|
|
54123
|
-
) == "bypassPermissions"
|
|
54243
|
+
and permission_mode == "bypassPermissions"
|
|
54124
54244
|
):
|
|
54125
54245
|
return PermissionResultAllow(updated_input=dict(input_data))
|
|
54126
54246
|
|
|
@@ -56355,6 +56475,7 @@ async def acquire_codex_control_thread(
|
|
|
56355
56475
|
"provider_turn_id": None,
|
|
56356
56476
|
"provider_turn_ready": False,
|
|
56357
56477
|
"interactive_app_server": True,
|
|
56478
|
+
"codex_approval_policy": effective_codex_approval_policy(session),
|
|
56358
56479
|
"codex_native_operation": True,
|
|
56359
56480
|
"codex_native_operation_kind": None,
|
|
56360
56481
|
"owner_task": reservation_task,
|
|
@@ -58792,6 +58913,15 @@ def record_runtime_success(backend: str) -> None:
|
|
|
58792
58913
|
|
|
58793
58914
|
async def ensure_runtime_available(backend: str, *, session: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
58794
58915
|
diagnostic = await asyncio.to_thread(runtime_diagnostic, backend)
|
|
58916
|
+
if (session or {}).get("provider_connection") == "custom":
|
|
58917
|
+
selected = PROVIDER_CONNECTION_STORE.for_session(session)
|
|
58918
|
+
if backend != BACKEND_CURSOR:
|
|
58919
|
+
provider_connections.require_model(selected)
|
|
58920
|
+
else:
|
|
58921
|
+
await cursor_api_key.require_isolation(await asyncio.to_thread(resolve_cursor_executable), runner_env())
|
|
58922
|
+
if backend not in {BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR} or diagnostic.get("installed") is not True:
|
|
58923
|
+
raise HTTPException(503, "Install this provider's native CLI to use its custom API.")
|
|
58924
|
+
diagnostic = {**diagnostic, "status": "ready", "authenticated": True}
|
|
58795
58925
|
if (
|
|
58796
58926
|
backend == BACKEND_CLAUDE
|
|
58797
58927
|
and diagnostic.get("installed") is True
|
|
@@ -59594,6 +59724,9 @@ def discover_runtime_backend_catalog(backend: str, *, force_runtime_probe: bool
|
|
|
59594
59724
|
})
|
|
59595
59725
|
catalog["diagnostic"] = public_runtime_diagnostic(diagnostic)
|
|
59596
59726
|
catalog["available"] = ready
|
|
59727
|
+
catalog["native_credentials_present"] = provider_connections.native_credentials_present(backend, diagnostic)
|
|
59728
|
+
if backend in {BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR}:
|
|
59729
|
+
catalog["custom_provider"] = PROVIDER_CONNECTION_STORE.catalog(backend, installed=diagnostic.get("installed") is True)
|
|
59597
59730
|
return catalog
|
|
59598
59731
|
|
|
59599
59732
|
|
|
@@ -60442,6 +60575,12 @@ def build_claude_cmd(
|
|
|
60442
60575
|
cmd.extend(["--settings", json.dumps({"env": {
|
|
60443
60576
|
"CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS": str(subagent_limit),
|
|
60444
60577
|
}}, separators=(",", ":"))])
|
|
60578
|
+
if sess.get("provider_connection") == "custom":
|
|
60579
|
+
_, settings_path = PROVIDER_CONNECTION_STORE.claude_overrides(sess)
|
|
60580
|
+
if "--settings" in cmd:
|
|
60581
|
+
index = cmd.index("--settings")
|
|
60582
|
+
del cmd[index:index + 2]
|
|
60583
|
+
cmd.extend(["--settings", settings_path])
|
|
60445
60584
|
if no_session_persistence and not provider_id:
|
|
60446
60585
|
cmd.append("--no-session-persistence")
|
|
60447
60586
|
if provider_id:
|
|
@@ -60497,6 +60636,7 @@ def claude_sdk_configuration_key(
|
|
|
60497
60636
|
"allowed_tools": [CLAUDE_PROVIDER_MCP_TOOL_NAME],
|
|
60498
60637
|
"thinking": {"type": "adaptive", "display": "summarized"},
|
|
60499
60638
|
"agentsdock_provider_tool": 1,
|
|
60639
|
+
"provider_connection_revision": sess.get("provider_connection_revision"),
|
|
60500
60640
|
}
|
|
60501
60641
|
subagent_limit = sess.get("subagent_limit")
|
|
60502
60642
|
if type(subagent_limit) is int and subagent_limit > 0:
|
|
@@ -60545,6 +60685,10 @@ def build_claude_sdk_options(
|
|
|
60545
60685
|
{"env": subagent_env}, separators=(",", ":"),
|
|
60546
60686
|
)
|
|
60547
60687
|
cli_path = claude_sdk_cli_path(env)
|
|
60688
|
+
if sess.get("provider_connection") == "custom":
|
|
60689
|
+
custom_env, custom_settings = PROVIDER_CONNECTION_STORE.claude_overrides(sess)
|
|
60690
|
+
env.update(custom_env)
|
|
60691
|
+
subagent_settings["settings"] = custom_settings
|
|
60548
60692
|
system_prompt = session_system_prompt(session_id, sess, manifest_path)
|
|
60549
60693
|
provider_id = resolve_claude_resume_provider(sess, cwd)[0]
|
|
60550
60694
|
if sess.get("fork_resume_session_at") and not (provider_id and sess.get("fork_from")):
|
|
@@ -60682,7 +60826,7 @@ def build_claude_sdk_options(
|
|
|
60682
60826
|
stderr=lambda line: logger.warning(
|
|
60683
60827
|
"Claude SDK stderr session=%s: %s",
|
|
60684
60828
|
session_id,
|
|
60685
|
-
compact_memory_text(line, 2_000),
|
|
60829
|
+
compact_memory_text(PROVIDER_CONNECTION_STORE.redact(sess, line), 2_000),
|
|
60686
60830
|
),
|
|
60687
60831
|
)
|
|
60688
60832
|
def bind_provider_tool_owner(ownership_token: str, run_id: str) -> None:
|
|
@@ -63296,7 +63440,7 @@ async def run_claude_print(
|
|
|
63296
63440
|
stdout=asyncio.subprocess.PIPE,
|
|
63297
63441
|
stderr=asyncio.subprocess.PIPE,
|
|
63298
63442
|
cwd=cwd,
|
|
63299
|
-
env=agent_runner_env(session_id, runtime_env),
|
|
63443
|
+
env={**agent_runner_env(session_id, runtime_env), **PROVIDER_CONNECTION_STORE.claude_overrides(sess)[0]} if sess.get("provider_connection") == "custom" else agent_runner_env(session_id, runtime_env),
|
|
63300
63444
|
limit=PROCESS_STREAM_LIMIT,
|
|
63301
63445
|
start_new_session=True,
|
|
63302
63446
|
)
|
|
@@ -66922,6 +67066,9 @@ async def run_cursor_process(
|
|
|
66922
67066
|
await append_event(session_id, "process_started", {"run_id": run_id, "backend": BACKEND_CURSOR, "argv": public_cmd, "cwd": cwd})
|
|
66923
67067
|
env = agent_runner_env(session_id)
|
|
66924
67068
|
env.update(sess.get("_cursor_tool_env") or {})
|
|
67069
|
+
# Last overlay: a saved key cannot inherit an auth-token override, write
|
|
67070
|
+
# the shared CLI credential store, or be redirected by project direnv.
|
|
67071
|
+
env.update(PROVIDER_CONNECTION_STORE.cursor_overrides(sess))
|
|
66925
67072
|
cursor_dir = os.path.dirname(os.path.abspath(cursor_bin))
|
|
66926
67073
|
if cursor_dir and cursor_dir not in env.get("PATH", "").split(os.pathsep):
|
|
66927
67074
|
env["PATH"] = cursor_dir + os.pathsep + env.get("PATH", "")
|
|
@@ -67557,7 +67704,7 @@ async def run_cursor_process(
|
|
|
67557
67704
|
)
|
|
67558
67705
|
cursor_auth_state = (
|
|
67559
67706
|
await asyncio.to_thread(cursor_auth_probe_state, cursor_bin)
|
|
67560
|
-
if cursor_auth_signal
|
|
67707
|
+
if cursor_auth_signal and sess.get("provider_connection") != "custom"
|
|
67561
67708
|
else None
|
|
67562
67709
|
)
|
|
67563
67710
|
cursor_auth_failed = cursor_auth_state == "unauthenticated"
|
|
@@ -67644,7 +67791,7 @@ async def run_cursor_process(
|
|
|
67644
67791
|
else None
|
|
67645
67792
|
)
|
|
67646
67793
|
)
|
|
67647
|
-
if not stopped and (
|
|
67794
|
+
if not stopped and sess.get("provider_connection") != "custom" and (
|
|
67648
67795
|
timeout_error
|
|
67649
67796
|
or stream_error
|
|
67650
67797
|
or protocol_error
|
|
@@ -67662,7 +67809,7 @@ async def run_cursor_process(
|
|
|
67662
67809
|
auth_failure=cursor_auth_failed,
|
|
67663
67810
|
runtime_error=cursor_auth_validation_error,
|
|
67664
67811
|
)
|
|
67665
|
-
elif not stopped:
|
|
67812
|
+
elif not stopped and sess.get("provider_connection") != "custom":
|
|
67666
67813
|
record_runtime_success(BACKEND_CURSOR)
|
|
67667
67814
|
|
|
67668
67815
|
successful_terminal = bool(
|
|
@@ -68178,6 +68325,45 @@ def opencode_helper_output_redactor(
|
|
|
68178
68325
|
|
|
68179
68326
|
|
|
68180
68327
|
async def run_opencode(
|
|
68328
|
+
session_id: str,
|
|
68329
|
+
run_id: str,
|
|
68330
|
+
prompt: str,
|
|
68331
|
+
sess: dict[str, Any],
|
|
68332
|
+
manifest_path: Path,
|
|
68333
|
+
**kwargs: Any,
|
|
68334
|
+
) -> None:
|
|
68335
|
+
"""Attach a private native MCP only for an admitted helper-capable run."""
|
|
68336
|
+
from cursor_provider_mcp import CursorToolBroker
|
|
68337
|
+
|
|
68338
|
+
async with CROSS_CHAT_CAPABILITY_LOCK:
|
|
68339
|
+
has_authority = any(c.get("source_session_id") == session_id
|
|
68340
|
+
and c.get("source_run_id") == run_id
|
|
68341
|
+
for c in CROSS_CHAT_CAPABILITIES.values())
|
|
68342
|
+
if not has_authority:
|
|
68343
|
+
return await run_opencode_process(session_id, run_id, prompt, sess, manifest_path, **kwargs)
|
|
68344
|
+
owner = secrets.token_urlsafe(48)
|
|
68345
|
+
name = "agentsdock_" + secrets.token_hex(16)
|
|
68346
|
+
|
|
68347
|
+
async def execute(value: Any, key: str) -> tuple[str, bool]:
|
|
68348
|
+
try:
|
|
68349
|
+
return await execute_provider_tool_once(
|
|
68350
|
+
session_id, run_id, value, replay_key="opencode:" + key,
|
|
68351
|
+
backend=BACKEND_OPENCODE, cursor_owner_token=owner,
|
|
68352
|
+
)
|
|
68353
|
+
except ProviderToolError as exc:
|
|
68354
|
+
return str(exc), True
|
|
68355
|
+
|
|
68356
|
+
broker = CursorToolBroker(codex_provider_mcp_tool_definition(), execute)
|
|
68357
|
+
try:
|
|
68358
|
+
tool_env = await broker.start()
|
|
68359
|
+
selected = {**sess, "_opencode_mcp_owner_token": owner,
|
|
68360
|
+
"_opencode_tool_env": tool_env, "_opencode_tool_name": name}
|
|
68361
|
+
await run_opencode_process(session_id, run_id, prompt, selected, manifest_path, **kwargs)
|
|
68362
|
+
finally:
|
|
68363
|
+
await broker.close()
|
|
68364
|
+
|
|
68365
|
+
|
|
68366
|
+
async def run_opencode_process(
|
|
68181
68367
|
session_id: str,
|
|
68182
68368
|
run_id: str,
|
|
68183
68369
|
prompt: str,
|
|
@@ -68206,6 +68392,7 @@ async def run_opencode(
|
|
|
68206
68392
|
OpenCodeEventParseError,
|
|
68207
68393
|
build_opencode_cmd,
|
|
68208
68394
|
build_opencode_env_overrides,
|
|
68395
|
+
build_opencode_mcp_overrides,
|
|
68209
68396
|
merge_opencode_usage,
|
|
68210
68397
|
new_opencode_enforced_agent_name,
|
|
68211
68398
|
normalize_opencode_stream_event,
|
|
@@ -68216,6 +68403,19 @@ async def run_opencode(
|
|
|
68216
68403
|
|
|
68217
68404
|
runtime_env = validate_provider_runtime_env(provider_runtime_env)
|
|
68218
68405
|
redact_helper_output = opencode_helper_output_redactor(session_id, run_id, runtime_env)
|
|
68406
|
+
tool_env = dict(sess.get("_opencode_tool_env") or {})
|
|
68407
|
+
tool_name = str(sess.get("_opencode_tool_name") or "")
|
|
68408
|
+
tool_owner = str(sess.get("_opencode_mcp_owner_token") or "")
|
|
68409
|
+
if tool_name:
|
|
68410
|
+
from cursor_provider_mcp import ENV_SECRET
|
|
68411
|
+
original_redact = redact_helper_output
|
|
68412
|
+
|
|
68413
|
+
def redact_helper_output(value: Any) -> str:
|
|
68414
|
+
text = original_redact(value)
|
|
68415
|
+
for private in (tool_env.get(ENV_SECRET), tool_owner):
|
|
68416
|
+
if private:
|
|
68417
|
+
text = text.replace(private, "<provider-private>")
|
|
68418
|
+
return text
|
|
68219
68419
|
if standalone_provider_context:
|
|
68220
68420
|
sess = standalone_provider_session(sess)
|
|
68221
68421
|
requested_cwd = str(sess.get("cwd") or DEFAULT_CWD)
|
|
@@ -68328,6 +68528,18 @@ async def run_opencode(
|
|
|
68328
68528
|
provider_prompt, instruction_hash, _injected, memory_injected = (
|
|
68329
68529
|
build_opencode_provider_prompt(session_id, sess, prompt, manifest_path)
|
|
68330
68530
|
)
|
|
68531
|
+
if tool_name:
|
|
68532
|
+
# The key changes on every run. Old transcript tool names/IPC bearers
|
|
68533
|
+
# cannot grant access to a later turn. No helper shell fallback.
|
|
68534
|
+
provider_prompt = (
|
|
68535
|
+
f"[Current run provider tool]\nUse only {tool_name}_run for the AgentsDock "
|
|
68536
|
+
"Chats, Jobs, Publish, Emergency, Mail and Team helpers. Pass helper, arguments "
|
|
68537
|
+
"and optional stdin; never pass authority or chat identity flags. "
|
|
68538
|
+
"This replaces any earlier internal provider tool name. Its server checks "
|
|
68539
|
+
"this exact live run and existing user authorization. Do not use shell "
|
|
68540
|
+
"helper commands or unrelated MCP servers for these actions.\n\n"
|
|
68541
|
+
+ provider_prompt.replace(CLAUDE_PROVIDER_MCP_TOOL_NAME, tool_name + "_run")
|
|
68542
|
+
)
|
|
68331
68543
|
permission_mode = effective_opencode_permission_mode(sess)
|
|
68332
68544
|
enforced_agent_name = (
|
|
68333
68545
|
new_opencode_enforced_agent_name()
|
|
@@ -68343,7 +68555,7 @@ async def run_opencode(
|
|
|
68343
68555
|
cmd = build_opencode_cmd(
|
|
68344
68556
|
{
|
|
68345
68557
|
"opencode_session_id": resumed_provider_id,
|
|
68346
|
-
"model": sess.get("model"),
|
|
68558
|
+
"model": "agentsdock_custom/" + provider_connections.require_model(PROVIDER_CONNECTION_STORE.for_session(sess)) if sess.get("provider_connection") == "custom" else sess.get("model"),
|
|
68347
68559
|
"effort": sess.get("effort"),
|
|
68348
68560
|
},
|
|
68349
68561
|
"",
|
|
@@ -68379,6 +68591,7 @@ async def run_opencode(
|
|
|
68379
68591
|
instruction_content
|
|
68380
68592
|
)
|
|
68381
68593
|
env = agent_runner_env(session_id, runtime_env)
|
|
68594
|
+
env.update(PROVIDER_CONNECTION_STORE.opencode_overrides(sess, env))
|
|
68382
68595
|
env.update(build_opencode_env_overrides(
|
|
68383
68596
|
permission_mode,
|
|
68384
68597
|
existing_config=env.get("OPENCODE_CONFIG_CONTENT"),
|
|
@@ -68388,6 +68601,12 @@ async def run_opencode(
|
|
|
68388
68601
|
deny_skill_tool=provider_command is not None,
|
|
68389
68602
|
enforced_agent_name=enforced_agent_name,
|
|
68390
68603
|
))
|
|
68604
|
+
if tool_name:
|
|
68605
|
+
env.update(build_opencode_mcp_overrides(
|
|
68606
|
+
env.get("OPENCODE_CONFIG_CONTENT"), name=tool_name,
|
|
68607
|
+
command=[sys.executable, str(CURSOR_PROCESS_GUARD.with_name("cursor_provider_mcp.py")), "--mcp"],
|
|
68608
|
+
environment=tool_env, enforced_agent_name=enforced_agent_name,
|
|
68609
|
+
))
|
|
68391
68610
|
opencode_dir = os.path.dirname(os.path.abspath(opencode_bin))
|
|
68392
68611
|
if opencode_dir and opencode_dir not in env.get("PATH", "").split(os.pathsep):
|
|
68393
68612
|
env["PATH"] = opencode_dir + os.pathsep + env.get("PATH", "")
|
|
@@ -68452,6 +68671,8 @@ async def run_opencode(
|
|
|
68452
68671
|
"run_id": run_id,
|
|
68453
68672
|
"backend": BACKEND_OPENCODE,
|
|
68454
68673
|
"transport": "exec",
|
|
68674
|
+
"opencode_mcp_owner_token": tool_owner,
|
|
68675
|
+
"provider_tools_ready": asyncio.Event(),
|
|
68455
68676
|
"pid": proc.pid,
|
|
68456
68677
|
"pgid": pgid,
|
|
68457
68678
|
"cwd": cwd,
|
|
@@ -68861,6 +69082,10 @@ async def run_opencode(
|
|
|
68861
69082
|
provider_id = event_provider_id
|
|
68862
69083
|
provider_started = True
|
|
68863
69084
|
await mark_provider_turn_ready(session_id, run_id, provider_id)
|
|
69085
|
+
async with ACTIVE_LOCK:
|
|
69086
|
+
active = ACTIVE.get(session_id) or {}
|
|
69087
|
+
if active.get("run_id") == run_id and isinstance(active.get("provider_tools_ready"), asyncio.Event):
|
|
69088
|
+
active["provider_tools_ready"].set()
|
|
68864
69089
|
if normalized is None:
|
|
68865
69090
|
continue
|
|
68866
69091
|
# Detect the forbidden tool on the raw normalized event. In
|
|
@@ -69559,6 +69784,11 @@ async def run_codex_app_server(
|
|
|
69559
69784
|
sess = standalone_provider_session(sess)
|
|
69560
69785
|
requested_cwd = str(sess.get("cwd") or DEFAULT_CWD)
|
|
69561
69786
|
cwd = existing_cwd(requested_cwd)
|
|
69787
|
+
approval_policy = (
|
|
69788
|
+
effective_codex_approval_policy(sess)
|
|
69789
|
+
if interactive_app_server
|
|
69790
|
+
else CODEX_NONINTERACTIVE_APPROVAL_POLICY
|
|
69791
|
+
)
|
|
69562
69792
|
if diff_baseline is None:
|
|
69563
69793
|
diff_baseline = await capture_git_baseline(session_id, run_id, cwd)
|
|
69564
69794
|
if str(Path(requested_cwd).expanduser()) != cwd:
|
|
@@ -69606,6 +69836,7 @@ async def run_codex_app_server(
|
|
|
69606
69836
|
current_run_id = run_id
|
|
69607
69837
|
current_provider_prompt = prompt
|
|
69608
69838
|
current_diff_baseline = diff_baseline
|
|
69839
|
+
initial_provider_turn_id = ""
|
|
69609
69840
|
manifest_watch_task: asyncio.Task[None] | None = None
|
|
69610
69841
|
goal_time_budget_task: asyncio.Task[None] | None = None
|
|
69611
69842
|
goal_continuation_result: dict[str, Any] | None = None
|
|
@@ -70060,7 +70291,11 @@ async def run_codex_app_server(
|
|
|
70060
70291
|
return text
|
|
70061
70292
|
|
|
70062
70293
|
def current_metadata() -> dict[str, Any]:
|
|
70063
|
-
return
|
|
70294
|
+
return {
|
|
70295
|
+
**run_event_metadata(current_run_id),
|
|
70296
|
+
**({"provider_initial_turn_id": initial_provider_turn_id,
|
|
70297
|
+
"provider_thread_id": provider_id} if initial_provider_turn_id else {}),
|
|
70298
|
+
}
|
|
70064
70299
|
|
|
70065
70300
|
async def emit_final_text(value: Any, *, item_id: str = "") -> None:
|
|
70066
70301
|
text = clean_assistant_text(str(value or ""))
|
|
@@ -70401,7 +70636,7 @@ async def run_codex_app_server(
|
|
|
70401
70636
|
) -> dict[str, Any]:
|
|
70402
70637
|
nonlocal current_run_id, current_provider_prompt
|
|
70403
70638
|
nonlocal current_diff_baseline, manifest_watch_task, last_activity
|
|
70404
|
-
nonlocal delivery_unknown
|
|
70639
|
+
nonlocal delivery_unknown, initial_provider_turn_id
|
|
70405
70640
|
if turn is None or turn_completed:
|
|
70406
70641
|
raise NativeSteerHandoffError(
|
|
70407
70642
|
"the active Codex turn has already completed",
|
|
@@ -70559,7 +70794,7 @@ async def run_codex_app_server(
|
|
|
70559
70794
|
client_user_message_id=candidate_run_id,
|
|
70560
70795
|
)
|
|
70561
70796
|
else:
|
|
70562
|
-
await turn.steer(
|
|
70797
|
+
_turn_id = await turn.steer(
|
|
70563
70798
|
[{
|
|
70564
70799
|
"type": "text",
|
|
70565
70800
|
"text": request_prompt,
|
|
@@ -70713,7 +70948,7 @@ async def run_codex_app_server(
|
|
|
70713
70948
|
await join_task_despite_caller_cancellation(completion)
|
|
70714
70949
|
|
|
70715
70950
|
try:
|
|
70716
|
-
previous_metadata =
|
|
70951
|
+
previous_metadata = current_metadata()
|
|
70717
70952
|
with suppress(Exception):
|
|
70718
70953
|
await flush_pending_unknown(final=False)
|
|
70719
70954
|
previous_watcher = manifest_watch_task
|
|
@@ -70765,6 +71000,9 @@ async def run_codex_app_server(
|
|
|
70765
71000
|
)
|
|
70766
71001
|
stopped_during_handoff = bool(active.get("stop_requested"))
|
|
70767
71002
|
current_run_id = candidate_run_id
|
|
71003
|
+
# Force Send accepts a new logical input on the current
|
|
71004
|
+
# native turn; it must not inherit the predecessor's input ID.
|
|
71005
|
+
initial_provider_turn_id = str(_turn_id or "")
|
|
70768
71006
|
reasoning_stream_run_ids.add(candidate_run_id)
|
|
70769
71007
|
current_provider_prompt = request_prompt
|
|
70770
71008
|
current_diff_baseline = candidate_diff_baseline
|
|
@@ -70908,6 +71146,7 @@ async def run_codex_app_server(
|
|
|
70908
71146
|
}
|
|
70909
71147
|
|
|
70910
71148
|
async def bind_active_turn_and_reconcile_stop() -> None:
|
|
71149
|
+
nonlocal initial_provider_turn_id
|
|
70911
71150
|
if turn is None or not turn.turn_id:
|
|
70912
71151
|
return
|
|
70913
71152
|
should_interrupt = False
|
|
@@ -70928,6 +71167,8 @@ async def run_codex_app_server(
|
|
|
70928
71167
|
)
|
|
70929
71168
|
)
|
|
70930
71169
|
if active_owned:
|
|
71170
|
+
if not initial_provider_turn_id:
|
|
71171
|
+
initial_provider_turn_id = str(getattr(turn, "initial_turn_id", "") or turn.turn_id)
|
|
70931
71172
|
active["provider_turn_ready"] = True
|
|
70932
71173
|
active["provider_turn_id"] = turn.turn_id
|
|
70933
71174
|
if (
|
|
@@ -71347,6 +71588,7 @@ async def run_codex_app_server(
|
|
|
71347
71588
|
"native_interrupt_sent": False,
|
|
71348
71589
|
"codex_app_server_turn": None,
|
|
71349
71590
|
"interactive_app_server": interactive_app_server,
|
|
71591
|
+
"codex_approval_policy": approval_policy,
|
|
71350
71592
|
# turn/steer cannot replace the run-bound Responses metadata.
|
|
71351
71593
|
# Authority-bearing Force Send therefore uses the existing
|
|
71352
71594
|
# Stop -> queued fresh-start lifecycle; the thread remains
|
|
@@ -71439,23 +71681,7 @@ async def run_codex_app_server(
|
|
|
71439
71681
|
if interactive_app_server
|
|
71440
71682
|
else ""
|
|
71441
71683
|
)
|
|
71442
|
-
|
|
71443
|
-
str(
|
|
71444
|
-
sess.get("codex_approval_policy")
|
|
71445
|
-
or CODEX_DEFAULT_APPROVAL_POLICY
|
|
71446
|
-
)
|
|
71447
|
-
if interactive_app_server
|
|
71448
|
-
else CODEX_NONINTERACTIVE_APPROVAL_POLICY
|
|
71449
|
-
)
|
|
71450
|
-
overrides["approvalPolicy"] = (
|
|
71451
|
-
approval_policy
|
|
71452
|
-
if approval_policy in CODEX_APPROVAL_POLICIES
|
|
71453
|
-
else (
|
|
71454
|
-
CODEX_DEFAULT_APPROVAL_POLICY
|
|
71455
|
-
if interactive_app_server
|
|
71456
|
-
else CODEX_NONINTERACTIVE_APPROVAL_POLICY
|
|
71457
|
-
)
|
|
71458
|
-
)
|
|
71684
|
+
overrides["approvalPolicy"] = approval_policy
|
|
71459
71685
|
if permission_profile:
|
|
71460
71686
|
# Permission profiles are an experimental app-server
|
|
71461
71687
|
# override and are mutually exclusive with sandbox policy,
|
|
@@ -79015,6 +79241,8 @@ async def require_agent_token(request: Request, call_next):
|
|
|
79015
79241
|
"/api/admin/codex/auth", "/api/admin/codex/auth/api-key",
|
|
79016
79242
|
"/api/admin/codex/provider", "/api/admin/codex/provider/test",
|
|
79017
79243
|
"/api/admin/codex/provider/models",
|
|
79244
|
+
"/api/admin/provider-connections/claude", "/api/admin/provider-connections/claude/check",
|
|
79245
|
+
"/api/admin/provider-connections/opencode", "/api/admin/provider-connections/opencode/check",
|
|
79018
79246
|
}
|
|
79019
79247
|
public_chat_shares_admin_route = (
|
|
79020
79248
|
request.url.path == "/api/admin/chat-shares"
|
|
@@ -79214,10 +79442,12 @@ async def require_agent_token(request: Request, call_next):
|
|
|
79214
79442
|
if body_error is not None:
|
|
79215
79443
|
status_code, detail = body_error
|
|
79216
79444
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
|
79217
|
-
elif request.url.path in {"/api/admin/codex/provider", "/api/admin/codex/provider/test", "/api/admin/codex/provider/models"}
|
|
79445
|
+
elif (request.url.path in {"/api/admin/codex/provider", "/api/admin/codex/provider/test", "/api/admin/codex/provider/models"}
|
|
79446
|
+
and request.method.upper() in {"PUT", "POST"}) or (request.url.path.startswith("/api/admin/provider-connections/")
|
|
79447
|
+
and request.method.upper() in {"PUT", "POST", "DELETE"}):
|
|
79218
79448
|
declared_size, transport_error = privileged_native_json_transport(
|
|
79219
79449
|
request, max_body_bytes=codex_provider.MAX_BODY_BYTES,
|
|
79220
|
-
label="
|
|
79450
|
+
label="Provider endpoint", require_content_length=True,
|
|
79221
79451
|
)
|
|
79222
79452
|
if transport_error is not None:
|
|
79223
79453
|
status_code, detail = transport_error
|
|
@@ -81550,6 +81780,7 @@ async def health() -> dict[str, Any]:
|
|
|
81550
81780
|
"side_questions": side_questions.capability(),
|
|
81551
81781
|
"codex_auth_v1": codex_auth.capability(available=bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC),
|
|
81552
81782
|
"codex_provider_v1": {"available": bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC, "wire_api": "responses", "per_chat": True, "per_chat_models": True, "model_discovery": True, "model_compatibility": True},
|
|
81783
|
+
"provider_connections_v1": {"available": bool(AGENT_TOKEN), "per_chat": True, "backends": [BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR]},
|
|
81553
81784
|
"team_mail_hints_v1": SECURE_PEER_RUNTIME.team_mail_hint_capability(),
|
|
81554
81785
|
"team_mail_hints_v2": SECURE_PEER_RUNTIME.team_notification_hint_capability(),
|
|
81555
81786
|
"websocket_auth_v1": {
|
|
@@ -82977,6 +83208,38 @@ async def custom_codex_discovery_native_models() -> dict:
|
|
|
82977
83208
|
return await asyncio.to_thread(lambda: json.loads(Path(path).read_text(encoding="utf-8")))
|
|
82978
83209
|
|
|
82979
83210
|
|
|
83211
|
+
async def set_codex_default_model(model: str | None, expected_revision):
|
|
83212
|
+
async with CODEX_PROVIDER_SETTINGS_LOCK:
|
|
83213
|
+
selected = await asyncio.to_thread(CODEX_PROVIDER_STORE.selection, include_key=True, include_revision=True)
|
|
83214
|
+
if not selected or selected.get("credential_id") != expected_revision:
|
|
83215
|
+
raise HTTPException(409, "Endpoint settings changed. Refresh and try again.")
|
|
83216
|
+
status = await asyncio.to_thread(CODEX_PROVIDER_STORE.status)
|
|
83217
|
+
if status.get("connection_verified") is not True:
|
|
83218
|
+
raise HTTPException(409, "Check this API connection first.")
|
|
83219
|
+
replacement = {"base_url": selected["base_url"], "api_key": selected["api_key"],
|
|
83220
|
+
"model": model, "connection_verified": True}
|
|
83221
|
+
task = asyncio.create_task(replace_codex_provider_settings(replacement))
|
|
83222
|
+
try:
|
|
83223
|
+
await asyncio.shield(task)
|
|
83224
|
+
except BaseException:
|
|
83225
|
+
with suppress(BaseException): await join_task_despite_caller_cancellation(task)
|
|
83226
|
+
raise
|
|
83227
|
+
current = await asyncio.to_thread(CODEX_PROVIDER_STORE.status)
|
|
83228
|
+
return {"backend": "codex", "revision": current["credential_id"], "default_model": current["model"]}
|
|
83229
|
+
|
|
83230
|
+
|
|
83231
|
+
app.include_router(provider_connections.create_router(
|
|
83232
|
+
authorize=require_native_admin_control, store=PROVIDER_CONNECTION_STORE,
|
|
83233
|
+
cursor_catalog=lambda selected: cursor_api_key.catalog(selected, executable=resolve_cursor_executable(), env=runner_env()),
|
|
83234
|
+
codex_store=CODEX_PROVIDER_STORE, codex_set_model=set_codex_default_model,
|
|
83235
|
+
session_lookup=lambda session_id: STORE.sessions.get(session_id),
|
|
83236
|
+
account=lambda backend: provider_connections.native_account_metadata(
|
|
83237
|
+
backend, env=runner_env(),
|
|
83238
|
+
cursor_executable=shutil.which(CURSOR_BIN, path=runner_env().get("PATH")) if backend == BACKEND_CURSOR else None,
|
|
83239
|
+
command=lambda cmd: runtime_command(cmd, timeout_seconds=4),
|
|
83240
|
+
),
|
|
83241
|
+
))
|
|
83242
|
+
|
|
82980
83243
|
app.include_router(codex_provider.create_router(
|
|
82981
83244
|
authorize=require_native_admin_control,
|
|
82982
83245
|
store=CODEX_PROVIDER_STORE,
|
|
@@ -82985,6 +83248,7 @@ app.include_router(codex_provider.create_router(
|
|
|
82985
83248
|
available=lambda: CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC,
|
|
82986
83249
|
session_lookup=lambda session_id: STORE.sessions.get(session_id),
|
|
82987
83250
|
native_models=custom_codex_discovery_native_models,
|
|
83251
|
+
check_credentials=provider_connections.probe_credentials,
|
|
82988
83252
|
))
|
|
82989
83253
|
|
|
82990
83254
|
|
|
@@ -83116,7 +83380,8 @@ async def create_native_side_chat(session_id: str, *, persisted_state=None, pers
|
|
|
83116
83380
|
and self.codex.thread_id
|
|
83117
83381
|
and str(params.get("threadId") or "") == self.codex.thread_id)
|
|
83118
83382
|
return await handle_codex_server_request(request_id, method, params,
|
|
83119
|
-
side_session_id=session_id, side_owner_is_current=is_current
|
|
83383
|
+
side_session_id=session_id, side_owner_is_current=is_current,
|
|
83384
|
+
side_approval_policy=approval_policy)
|
|
83120
83385
|
|
|
83121
83386
|
provider_selection = CODEX_PROVIDER_STORE.for_session(current, include_key=True)
|
|
83122
83387
|
if provider_selection:
|
|
@@ -89811,8 +90076,68 @@ async def acknowledge_session_emergency(
|
|
|
89811
90076
|
return {"session": public_session(sess), "acknowledged": True}
|
|
89812
90077
|
|
|
89813
90078
|
|
|
90079
|
+
async def ensure_discardable_empty_session(session_id: str, expected_updated_at: str) -> None:
|
|
90080
|
+
"""Fail closed while the caller holds the lifecycle lock/deletion fence.
|
|
90081
|
+
|
|
90082
|
+
Automatic placeholder cleanup must never behave like an explicit Delete:
|
|
90083
|
+
it may not interrupt a turn, cancel queued work or remove uploaded content.
|
|
90084
|
+
Local drafts are additionally checked by the originating desktop client.
|
|
90085
|
+
"""
|
|
90086
|
+
def untouched() -> bool:
|
|
90087
|
+
session = STORE.sessions.get(session_id)
|
|
90088
|
+
return bool(
|
|
90089
|
+
session and expected_updated_at
|
|
90090
|
+
and session.get("updated_at") == expected_updated_at
|
|
90091
|
+
and session.get("title") == "New chat"
|
|
90092
|
+
and not session_backend_locked(session)
|
|
90093
|
+
and not any(session.get(key) for key in (
|
|
90094
|
+
"parent_id", "pinned", "archived", "manual_unread",
|
|
90095
|
+
"codex_goal", "claude_goal", "emergency_alert",
|
|
90096
|
+
))
|
|
90097
|
+
and session_id not in ACTIVE and session_id not in BUSY_SESSIONS
|
|
90098
|
+
and session_id not in CURRENT_TURNS
|
|
90099
|
+
and not QUEUED_TURNS.get(session_id)
|
|
90100
|
+
and session_id not in RUN_NOW_TURNS
|
|
90101
|
+
and session_id not in RUN_NOW_REQUESTS
|
|
90102
|
+
and not any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))
|
|
90103
|
+
and not any(job.get("session_id") == session_id for job in JOBS.jobs.values())
|
|
90104
|
+
and not PORT_TUNNELS._sockets.get(session_id)
|
|
90105
|
+
)
|
|
90106
|
+
|
|
90107
|
+
def only_creation_event() -> bool:
|
|
90108
|
+
# Unlike timeline projection, do not skip malformed/hidden records.
|
|
90109
|
+
# Any extra byte or uncertain history preserves the chat.
|
|
90110
|
+
with events_path(session_id).open("rb") as stream:
|
|
90111
|
+
line = stream.readline(65537)
|
|
90112
|
+
if not line or len(line) > 65536 or stream.read(1):
|
|
90113
|
+
return False
|
|
90114
|
+
try:
|
|
90115
|
+
event = json.loads(line)
|
|
90116
|
+
except (ValueError, UnicodeError):
|
|
90117
|
+
return False
|
|
90118
|
+
return isinstance(event, dict) and event.get("type") == "session_created" and event.get("seq") == 1
|
|
90119
|
+
|
|
90120
|
+
if not untouched():
|
|
90121
|
+
raise HTTPException(status_code=409, detail="Chat is no longer an unused placeholder; it was preserved.")
|
|
90122
|
+
history_empty, files, terminal = await asyncio.gather(
|
|
90123
|
+
asyncio.to_thread(only_creation_event),
|
|
90124
|
+
asyncio.to_thread(list_session_file_records, session_id),
|
|
90125
|
+
asyncio.to_thread(terminal_windows_snapshot, session_id),
|
|
90126
|
+
)
|
|
90127
|
+
if not untouched() or not history_empty or files or terminal.get("exists"):
|
|
90128
|
+
raise HTTPException(status_code=409, detail="Chat has content or resources; it was preserved.")
|
|
90129
|
+
|
|
90130
|
+
|
|
90131
|
+
@app.post("/api/sessions/{session_id}/discard-empty")
|
|
90132
|
+
async def discard_empty_session(session_id: str, expected_updated_at: str) -> dict[str, Any]:
|
|
90133
|
+
# A separate route makes old servers fail safely with 404, rather than
|
|
90134
|
+
# ignoring a conditional DELETE query and deleting a real conversation.
|
|
90135
|
+
await wait_for_queue_recovery_admission()
|
|
90136
|
+
return await delete_session(session_id, discard_updated_at=expected_updated_at)
|
|
90137
|
+
|
|
90138
|
+
|
|
89814
90139
|
@app.delete("/api/sessions/{session_id}")
|
|
89815
|
-
async def delete_session(session_id: str) -> dict[str, Any]:
|
|
90140
|
+
async def delete_session(session_id: str, discard_updated_at: str | None = None) -> dict[str, Any]:
|
|
89816
90141
|
async with session_lifecycle_lock(session_id):
|
|
89817
90142
|
ensure_session_not_initializing(session_id)
|
|
89818
90143
|
if session_id in DELETED_SESSION_TOMBSTONES:
|
|
@@ -89847,9 +90172,11 @@ async def delete_session(session_id: str) -> dict[str, Any]:
|
|
|
89847
90172
|
),
|
|
89848
90173
|
)
|
|
89849
90174
|
DELETING_SESSIONS.add(session_id)
|
|
89850
|
-
cancel_generated_session_title(session_id)
|
|
89851
90175
|
deleted = False
|
|
89852
90176
|
try:
|
|
90177
|
+
if discard_updated_at is not None:
|
|
90178
|
+
await ensure_discardable_empty_session(session_id, discard_updated_at)
|
|
90179
|
+
cancel_generated_session_title(session_id)
|
|
89853
90180
|
if (
|
|
89854
90181
|
session_id in CLAUDE_STOP_FENCE_SESSIONS
|
|
89855
90182
|
or session_id in CLAUDE_STOP_FENCE_RETRY_TASKS
|
|
@@ -90107,6 +90434,8 @@ async def delete_session(session_id: str) -> dict[str, Any]:
|
|
|
90107
90434
|
"the session was not deleted. Retry shortly."
|
|
90108
90435
|
),
|
|
90109
90436
|
)
|
|
90437
|
+
if discard_updated_at is not None:
|
|
90438
|
+
await ensure_discardable_empty_session(session_id, discard_updated_at)
|
|
90110
90439
|
await fence_secure_peer_chat_retirement(session_id)
|
|
90111
90440
|
try:
|
|
90112
90441
|
await asyncio.to_thread(
|
|
@@ -90190,6 +90519,8 @@ async def delete_session(session_id: str) -> dict[str, Any]:
|
|
|
90190
90519
|
await asyncio.to_thread(kill_terminal_session, session_id)
|
|
90191
90520
|
async def finish_committed_delete() -> dict[str, Any]:
|
|
90192
90521
|
async with event_delivery_lock(session_id):
|
|
90522
|
+
if discard_updated_at is not None:
|
|
90523
|
+
await ensure_discardable_empty_session(session_id, discard_updated_at)
|
|
90193
90524
|
committed_deleted = await STORE.delete(session_id)
|
|
90194
90525
|
DELETED_SESSION_TOMBSTONES.add(session_id)
|
|
90195
90526
|
DELETING_SESSIONS.discard(session_id)
|
|
@@ -90574,6 +90905,7 @@ async def _fork_session_locked(
|
|
|
90574
90905
|
cwd=parent.get("cwd"),
|
|
90575
90906
|
backend=parent_backend,
|
|
90576
90907
|
codex_provider=codex_provider.session_choice(parent.get("codex_provider")),
|
|
90908
|
+
provider_connection=parent.get("provider_connection") or "default",
|
|
90577
90909
|
model=parent.get("model"),
|
|
90578
90910
|
effort=parent.get("effort"),
|
|
90579
90911
|
subagent_limit=parent.get("subagent_limit"),
|