@agentsdock/server 1.0.7 → 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.
@@ -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
- if clean_query:
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 == BACKEND_CURSOR
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 == BACKEND_CURSOR:
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
- and hmac.compare_digest(str(active.get("cursor_mcp_owner_token") or ""), cursor_owner_token)
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 != BACKEND_CURSOR:
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
- # Cursor has no separately selectable transport: the compatibility and
38398
- # authentication probe is the transport gate. Use only the cached probe
38399
- # here so /api/health never blocks on CLI subprocesses. Startup and the
38400
- # runtime catalog refresh this diagnostic, while actual turn admission
38401
- # calls ensure_runtime_available() again before any provider launch.
38402
- with RUNTIME_DIAGNOSTICS_LOCK:
38403
- cursor_diagnostic = dict(
38404
- RUNTIME_DIAGNOSTICS.get(BACKEND_CURSOR) or {}
38405
- )
38406
- if cursor_diagnostic.get("status") == "ready":
38407
- supported.append(BACKEND_CURSOR)
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 compatible authenticated Cursor CLI target "
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 current persisted mode at the ownership fence so an
54230
+ # Honor the captured mode at the ownership fence so an
54116
54231
  # unexpected callback cannot manufacture a desktop approval card.
54117
- # Explicit AskUserQuestion interactions remain user-facing questions.
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 active_claude_permission_mode(
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 run_event_metadata(current_run_id)
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 = run_event_metadata(previous_run_id)
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
- approval_policy = (
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"} and request.method.upper() in {"PUT", "POST"}:
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="Codex endpoint", require_content_length=True,
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"),