@agentsdock/server 1.0.10 → 1.0.11-beta.1
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/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +189 -185
- package/server/claude_history_repair.py +46 -61
- package/server/codex_auth.py +28 -7
- package/server/codex_history_repair.py +23 -23
- package/server/install.sh +3 -2
- package/server/server_instances.py +22 -0
- package/server/transcript_reader.py +322 -0
package/package.json
CHANGED
package/server/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.11-beta.1
|
package/server/agent_server.py
CHANGED
|
@@ -26,6 +26,7 @@ import importlib.util
|
|
|
26
26
|
import inspect
|
|
27
27
|
import io
|
|
28
28
|
import json
|
|
29
|
+
from transcript_reader import jsonl_records, scan_check, SCAN_SECONDS
|
|
29
30
|
import logging
|
|
30
31
|
import logging.handlers
|
|
31
32
|
import math
|
|
@@ -471,39 +472,17 @@ def boolean_setting(suffix: str, default: bool) -> bool:
|
|
|
471
472
|
return default
|
|
472
473
|
|
|
473
474
|
|
|
474
|
-
CODEX_GOALS_DEFAULT_ENABLED =
|
|
475
|
+
CODEX_GOALS_DEFAULT_ENABLED = True
|
|
475
476
|
|
|
476
477
|
|
|
477
478
|
def read_codex_goals_enabled(path: Path | None = None) -> bool:
|
|
478
|
-
"""
|
|
479
|
+
"""Goals are available without a server-wide opt-in.
|
|
479
480
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
481
|
+
Ignore the retired environment/config switch, including persisted False
|
|
482
|
+
from older clients. Do not rewrite shared settings or resume paused goals.
|
|
483
|
+
Keep the optional path argument for compatibility with existing callers.
|
|
483
484
|
"""
|
|
484
|
-
|
|
485
|
-
settings_path = path or CODEX_SETTINGS_FILE
|
|
486
|
-
try:
|
|
487
|
-
value = json.loads(settings_path.read_text())
|
|
488
|
-
except FileNotFoundError:
|
|
489
|
-
return CODEX_GOALS_DEFAULT_ENABLED
|
|
490
|
-
except (json.JSONDecodeError, OSError) as exc:
|
|
491
|
-
logger.warning(
|
|
492
|
-
"could not read Codex server settings from %s: %s",
|
|
493
|
-
settings_path,
|
|
494
|
-
exc,
|
|
495
|
-
)
|
|
496
|
-
return CODEX_GOALS_DEFAULT_ENABLED
|
|
497
|
-
if not isinstance(value, dict) or not isinstance(
|
|
498
|
-
value.get("goals_enabled"),
|
|
499
|
-
bool,
|
|
500
|
-
):
|
|
501
|
-
logger.warning(
|
|
502
|
-
"ignoring malformed Codex server settings at %s",
|
|
503
|
-
settings_path,
|
|
504
|
-
)
|
|
505
|
-
return CODEX_GOALS_DEFAULT_ENABLED
|
|
506
|
-
return bool(value["goals_enabled"])
|
|
485
|
+
return True
|
|
507
486
|
|
|
508
487
|
|
|
509
488
|
# Per-thread Codex config overrides (codex-cli 0.149.1: thread/start,
|
|
@@ -1319,8 +1298,9 @@ HISTORY_SYNC_EVENT_SCAN_LIMIT = int(
|
|
|
1319
1298
|
MAX_IMPORTED_TEXT_CHARS = int(agentsdock_setting("HISTORY_IMPORT_TEXT_CHARS", "12000"))
|
|
1320
1299
|
MAX_LOCAL_SESSION_LIST_ITEMS = 500
|
|
1321
1300
|
MAX_LOCAL_SESSION_SCAN_FILES = 10_000
|
|
1322
|
-
|
|
1323
|
-
|
|
1301
|
+
# Retained text/metadata budget. Opaque native media/tool bytes are streamed.
|
|
1302
|
+
# Private timeline/index readers still use this as their raw record bound.
|
|
1303
|
+
MAX_LOCAL_TRANSCRIPT_LINE_BYTES = 32 * 1024 * 1024
|
|
1324
1304
|
MAX_LOCAL_TRANSCRIPT_SCAN_LINES = 250_000
|
|
1325
1305
|
MAX_CODEX_SESSION_INDEX_BYTES = 16 * 1024 * 1024
|
|
1326
1306
|
MAX_CODEX_SESSION_INDEX_LINES = 100_000
|
|
@@ -10706,6 +10686,8 @@ class SessionStore:
|
|
|
10706
10686
|
"created_at": now,
|
|
10707
10687
|
"updated_at": now,
|
|
10708
10688
|
}
|
|
10689
|
+
if not active_provider_id and not parent_id and sess["title"] == "New chat":
|
|
10690
|
+
sess["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": {}}
|
|
10709
10691
|
if req.subagent_limit is not None:
|
|
10710
10692
|
sess["subagent_limit"] = req.subagent_limit
|
|
10711
10693
|
if initializing_fork and parent_id:
|
|
@@ -44941,98 +44923,44 @@ def bounded_jsonl_paths(
|
|
|
44941
44923
|
continue
|
|
44942
44924
|
|
|
44943
44925
|
|
|
44944
|
-
def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
|
|
44945
|
-
"""Parse a transcript with hard byte, line-size, and line-count bounds."""
|
|
44946
44926
|
|
|
44947
|
-
|
|
44948
|
-
|
|
44949
|
-
|
|
44950
|
-
|
|
44951
|
-
|
|
44952
|
-
consumed = 0
|
|
44927
|
+
|
|
44928
|
+
def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
|
|
44929
|
+
"""Project native records with bounded memory, not a source-byte cap."""
|
|
44930
|
+
initial = path.stat()
|
|
44931
|
+
expected = {field: int(getattr(initial, field)) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")}
|
|
44953
44932
|
with path.open("rb") as stream:
|
|
44954
|
-
|
|
44955
|
-
|
|
44956
|
-
|
|
44957
|
-
|
|
44958
|
-
|
|
44959
|
-
|
|
44960
|
-
|
|
44961
|
-
if len(raw_line) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
|
|
44962
|
-
raise ValueError(f"transcript line {line_number} exceeds the import limit")
|
|
44963
|
-
if not raw_line.strip():
|
|
44964
|
-
continue
|
|
44965
|
-
try:
|
|
44966
|
-
event = json.loads(raw_line)
|
|
44967
|
-
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
44968
|
-
if preserve_invalid:
|
|
44969
|
-
yield None
|
|
44970
|
-
continue
|
|
44971
|
-
if isinstance(event, dict):
|
|
44933
|
+
opened = os.fstat(stream.fileno())
|
|
44934
|
+
if any(int(getattr(opened, field)) != expected[field] for field in expected):
|
|
44935
|
+
raise ValueError("transcript changed before parsing")
|
|
44936
|
+
for event, _end, raw in jsonl_records(stream, end=initial.st_size,
|
|
44937
|
+
require_newline=False, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
|
|
44938
|
+
max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
|
|
44939
|
+
if not raw.blank and (event is not None or preserve_invalid):
|
|
44972
44940
|
yield event
|
|
44973
|
-
|
|
44974
|
-
|
|
44975
|
-
|
|
44976
|
-
raise ValueError(
|
|
44977
|
-
f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
|
|
44978
|
-
)
|
|
44941
|
+
finished = os.fstat(stream.fileno())
|
|
44942
|
+
if any(int(getattr(finished, field)) != expected[field] for field in expected):
|
|
44943
|
+
raise ValueError("transcript changed during parsing")
|
|
44979
44944
|
|
|
44980
44945
|
|
|
44981
44946
|
def bounded_jsonl_records_range(
|
|
44982
|
-
path: Path,
|
|
44983
|
-
|
|
44984
|
-
end: int,
|
|
44985
|
-
*,
|
|
44986
|
-
expected_stat: dict[str, int],
|
|
44947
|
+
path: Path, start: int, end: int, *, expected_stat: dict[str, int],
|
|
44948
|
+
require_newline: bool = True,
|
|
44987
44949
|
) -> Iterator[tuple[dict[str, Any] | None, int]]:
|
|
44988
44950
|
"""Read every record and ending offset in one immutable JSONL range."""
|
|
44989
|
-
|
|
44990
|
-
if start < 0 or end < start or end > MAX_LOCAL_TRANSCRIPT_BYTES:
|
|
44951
|
+
if start < 0 or end < start or end > expected_stat["st_size"]:
|
|
44991
44952
|
raise ValueError("transcript cursor range is invalid")
|
|
44992
|
-
remaining = end - start
|
|
44993
|
-
if remaining == 0:
|
|
44994
|
-
return
|
|
44995
44953
|
with path.open("rb") as stream:
|
|
44996
44954
|
before = os.fstat(stream.fileno())
|
|
44997
|
-
if any(
|
|
44998
|
-
int(getattr(before, field)) != int(expected_stat[field])
|
|
44999
|
-
for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")
|
|
45000
|
-
):
|
|
44955
|
+
if any(int(getattr(before, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
|
|
45001
44956
|
raise ValueError("transcript changed before cursor parsing")
|
|
45002
44957
|
stream.seek(start)
|
|
45003
|
-
for
|
|
45004
|
-
|
|
45005
|
-
|
|
45006
|
-
|
|
45007
|
-
min(MAX_LOCAL_TRANSCRIPT_LINE_BYTES + 1, remaining)
|
|
45008
|
-
)
|
|
45009
|
-
if not raw_line:
|
|
45010
|
-
raise ValueError("transcript was truncated during cursor parsing")
|
|
45011
|
-
remaining -= len(raw_line)
|
|
45012
|
-
if len(raw_line) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
|
|
45013
|
-
raise ValueError(
|
|
45014
|
-
f"transcript cursor line {line_number} exceeds the import limit"
|
|
45015
|
-
)
|
|
45016
|
-
if not raw_line.endswith(b"\n"):
|
|
45017
|
-
raise ValueError("transcript cursor does not end on a complete line")
|
|
45018
|
-
event: dict[str, Any] | None = None
|
|
45019
|
-
if raw_line.strip():
|
|
45020
|
-
try:
|
|
45021
|
-
loaded = json.loads(raw_line)
|
|
45022
|
-
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
45023
|
-
loaded = None
|
|
45024
|
-
if isinstance(loaded, dict):
|
|
45025
|
-
event = loaded
|
|
45026
|
-
yield event, int(stream.tell())
|
|
45027
|
-
if remaining > 0:
|
|
45028
|
-
raise ValueError(
|
|
45029
|
-
f"transcript cursor range exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
|
|
45030
|
-
)
|
|
44958
|
+
for event, offset, _raw in jsonl_records(stream, end=end,
|
|
44959
|
+
require_newline=require_newline, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
|
|
44960
|
+
max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
|
|
44961
|
+
yield event, offset
|
|
45031
44962
|
after = os.fstat(stream.fileno())
|
|
45032
|
-
if any(
|
|
45033
|
-
int(getattr(after, field)) != int(expected_stat[field])
|
|
45034
|
-
for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")
|
|
45035
|
-
):
|
|
44963
|
+
if any(int(getattr(after, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
|
|
45036
44964
|
raise ValueError("transcript changed during cursor parsing")
|
|
45037
44965
|
|
|
45038
44966
|
|
|
@@ -47494,7 +47422,6 @@ def normalized_history_sync_cursor(
|
|
|
47494
47422
|
or int(raw.get(field, 0)) < 0
|
|
47495
47423
|
for field in optional_integer_fields
|
|
47496
47424
|
)
|
|
47497
|
-
or int(raw["source_size"]) > MAX_LOCAL_TRANSCRIPT_BYTES
|
|
47498
47425
|
or int(raw["source_offset"]) > int(raw["source_size"])
|
|
47499
47426
|
or (
|
|
47500
47427
|
timeline_pending_active
|
|
@@ -47675,10 +47602,6 @@ def provider_history_source_snapshot(
|
|
|
47675
47602
|
"""Hash a stable transcript through its last complete JSONL record."""
|
|
47676
47603
|
|
|
47677
47604
|
initial = path.stat()
|
|
47678
|
-
if initial.st_size > MAX_LOCAL_TRANSCRIPT_BYTES:
|
|
47679
|
-
raise ValueError(
|
|
47680
|
-
f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_BYTES}-byte import limit"
|
|
47681
|
-
)
|
|
47682
47605
|
# Provider CLIs may rotate a JSONL file with atomic replacement or move it
|
|
47683
47606
|
# between dated directories. Backend/provider identity is validated before
|
|
47684
47607
|
# this helper; byte-for-byte prefix continuity is authoritative across a
|
|
@@ -47693,7 +47616,8 @@ def provider_history_source_snapshot(
|
|
|
47693
47616
|
prefix_digest = hashlib.sha256() if verify_offset is not None else None
|
|
47694
47617
|
prefix_hashed = 0
|
|
47695
47618
|
complete_size = 0
|
|
47696
|
-
|
|
47619
|
+
running_digest = hashlib.sha256()
|
|
47620
|
+
deadline = time.monotonic() + SCAN_SECONDS
|
|
47697
47621
|
with path.open("rb") as stream:
|
|
47698
47622
|
opened = os.fstat(stream.fileno())
|
|
47699
47623
|
expected_identity = (
|
|
@@ -47719,15 +47643,15 @@ def provider_history_source_snapshot(
|
|
|
47719
47643
|
take = min(len(chunk), int(verify_offset) - prefix_hashed)
|
|
47720
47644
|
prefix_digest.update(chunk[:take])
|
|
47721
47645
|
prefix_hashed += take
|
|
47722
|
-
|
|
47723
|
-
newline =
|
|
47646
|
+
scan_check(deadline, budget_seconds=SCAN_SECONDS)
|
|
47647
|
+
newline = chunk.rfind(b"\n")
|
|
47724
47648
|
if newline >= 0:
|
|
47725
|
-
|
|
47726
|
-
complete_digest.
|
|
47727
|
-
complete_size
|
|
47728
|
-
|
|
47729
|
-
|
|
47730
|
-
|
|
47649
|
+
running_digest.update(chunk[:newline + 1])
|
|
47650
|
+
complete_digest = running_digest.copy()
|
|
47651
|
+
complete_size = int(initial.st_size) - remaining - len(chunk) + newline + 1
|
|
47652
|
+
running_digest.update(chunk[newline + 1:])
|
|
47653
|
+
else:
|
|
47654
|
+
running_digest.update(chunk)
|
|
47731
47655
|
finished = os.fstat(stream.fileno())
|
|
47732
47656
|
if (
|
|
47733
47657
|
int(finished.st_dev),
|
|
@@ -50280,6 +50204,9 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50280
50204
|
# Provider ids are intentionally omitted from summary responses, but the
|
|
50281
50205
|
# UI still needs the authoritative first-turn backend fence.
|
|
50282
50206
|
public["backend_locked"] = session_backend_locked(sess)
|
|
50207
|
+
# Capability, not proof of emptiness: dismiss-empty repeats the full proof.
|
|
50208
|
+
if sess.get("title") == "New chat" and not public["backend_locked"]:
|
|
50209
|
+
public["empty_chat_cleanup"] = "client-v1"
|
|
50283
50210
|
public["codex_provider"] = codex_provider.session_choice(sess.get("codex_provider"))
|
|
50284
50211
|
connection = sess.get("provider_connection") or "default"
|
|
50285
50212
|
# Keep legacy native summaries sparse, but retain a stored default as the
|
|
@@ -54177,26 +54104,29 @@ async def refresh_codex_app_server_binary(*, force: bool = False) -> None:
|
|
|
54177
54104
|
schedule_codex_manager_drain()
|
|
54178
54105
|
|
|
54179
54106
|
|
|
54180
|
-
async def refresh_codex_app_server_login(*, request_handoff: bool = False) ->
|
|
54107
|
+
async def refresh_codex_app_server_login(*, request_handoff: bool = False, source: str = "manager_lookup") -> bool:
|
|
54181
54108
|
"""Change normal-Codex admission; never kill a credential-owning process.
|
|
54182
54109
|
|
|
54183
|
-
|
|
54184
|
-
|
|
54185
|
-
|
|
54110
|
+
Ordinary lookups/catalog refreshes only observe native file revisions.
|
|
54111
|
+
Explicit native-admin login reload also covers opaque credential stores.
|
|
54112
|
+
This is not a token refresh or login operation.
|
|
54186
54113
|
"""
|
|
54187
54114
|
global CODEX_APP_SERVER_MANAGER, CODEX_LOGIN_REVISION
|
|
54188
54115
|
if CODEX_TRANSPORT == CODEX_TRANSPORT_EXEC or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
|
|
54189
|
-
return
|
|
54116
|
+
return False
|
|
54117
|
+
# Sources are server-owned labels, never request text, account IDs or keys.
|
|
54118
|
+
if source not in {"manager_lookup", "turn_admission", "account_status", "catalog_refresh", "manual_login_reload"}:
|
|
54119
|
+
source = "manager_lookup"
|
|
54190
54120
|
async with CODEX_LOGIN_PROBE_LOCK:
|
|
54191
54121
|
if not await asyncio.to_thread(codex_auth.native_login_handoff_supported,
|
|
54192
54122
|
codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD)):
|
|
54193
|
-
return
|
|
54123
|
+
return False
|
|
54194
54124
|
revision = await asyncio.to_thread(codex_auth.native_login_revision,
|
|
54195
54125
|
codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD))
|
|
54196
54126
|
async with CODEX_GOALS_CONFIG_LOCK:
|
|
54197
54127
|
async with CODEX_APP_SERVER_MANAGER_LOCK:
|
|
54198
54128
|
if CODEX_APP_SERVER_MANAGER_CLEANUP_EPOCH is not None or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
|
|
54199
|
-
return
|
|
54129
|
+
return False
|
|
54200
54130
|
observed_change = revision is not None and revision != CODEX_LOGIN_REVISION
|
|
54201
54131
|
CODEX_LOGIN_REVISION = revision
|
|
54202
54132
|
if observed_change or request_handoff:
|
|
@@ -54212,12 +54142,17 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
|
|
|
54212
54142
|
if revision is not None and revision == previous:
|
|
54213
54143
|
manager._agentsdock_login_superseded = False
|
|
54214
54144
|
if changed or (request_handoff and revision is None):
|
|
54145
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is not True:
|
|
54146
|
+
reason = "login_revision_changed" if changed else "explicit_reload_opaque_login"
|
|
54147
|
+
manager._agentsdock_login_handoff_source = source
|
|
54148
|
+
logger.info("Codex login handoff requested source=%s reason=%s manager=%x", source, reason, id(manager))
|
|
54215
54149
|
manager._agentsdock_login_superseded = True
|
|
54216
54150
|
if not any(manager is item for item in CODEX_RETIRED_APP_SERVER_MANAGERS):
|
|
54217
54151
|
CODEX_RETIRED_APP_SERVER_MANAGERS.append(manager)
|
|
54218
54152
|
if CODEX_APP_SERVER_MANAGER is manager:
|
|
54219
54153
|
CODEX_APP_SERVER_MANAGER = None
|
|
54220
54154
|
schedule_codex_manager_drain()
|
|
54155
|
+
return True
|
|
54221
54156
|
|
|
54222
54157
|
|
|
54223
54158
|
def codex_manager_session_blocker(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> str | None:
|
|
@@ -54320,6 +54255,17 @@ def watch_codex_provider_handoff_blockers(
|
|
|
54320
54255
|
|
|
54321
54256
|
async def release_idle_codex_manager_session(manager: CodexAppServerManager, session_id: str, *, ignore_task=None) -> bool:
|
|
54322
54257
|
"""Called under the session lifecycle lock; retain ownership on any doubt."""
|
|
54258
|
+
def retained(reason: str) -> bool:
|
|
54259
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is True:
|
|
54260
|
+
reasons = getattr(manager, "_agentsdock_login_handoff_blockers", None)
|
|
54261
|
+
if reasons is None:
|
|
54262
|
+
reasons = manager._agentsdock_login_handoff_blockers = {}
|
|
54263
|
+
if reasons.get(session_id) != reason:
|
|
54264
|
+
reasons[session_id] = reason
|
|
54265
|
+
logger.info("Codex login handoff waiting source=%s manager=%x session=%s reason=%s",
|
|
54266
|
+
getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id, reason)
|
|
54267
|
+
return False
|
|
54268
|
+
|
|
54323
54269
|
def blocked(*, own_maintenance=False):
|
|
54324
54270
|
return (codex_manager_session_busy(manager, session_id, ignore_task=ignore_task,
|
|
54325
54271
|
ignore_maintenance=own_maintenance)
|
|
@@ -54360,7 +54306,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
|
|
|
54360
54306
|
sum(not task.done() for task in manager.client._server_request_tasks.values()),
|
|
54361
54307
|
error_type,
|
|
54362
54308
|
)
|
|
54363
|
-
return
|
|
54309
|
+
return retained(reasons[session_id])
|
|
54364
54310
|
|
|
54365
54311
|
if blocked():
|
|
54366
54312
|
return await defer("runtime_borrowers")
|
|
@@ -54440,6 +54386,10 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
|
|
|
54440
54386
|
if CODEX_SESSION_APP_SERVER_MANAGERS.get(session_id) is manager:
|
|
54441
54387
|
CODEX_SESSION_APP_SERVER_MANAGERS.pop(session_id, None)
|
|
54442
54388
|
CODEX_GOAL_SYNC_GENERATIONS.pop(session_id, None)
|
|
54389
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is True:
|
|
54390
|
+
getattr(manager, "_agentsdock_login_handoff_blockers", {}).pop(session_id, None)
|
|
54391
|
+
logger.info("Codex login handoff released source=%s manager=%x session=%s",
|
|
54392
|
+
getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id)
|
|
54443
54393
|
return True
|
|
54444
54394
|
except Exception as exc:
|
|
54445
54395
|
logger.debug("Codex handoff retained an unverified idle owner", exc_info=True)
|
|
@@ -54459,7 +54409,7 @@ async def prepare_codex_login_turn(session: dict[str, Any]) -> None:
|
|
|
54459
54409
|
session.pop("_codex_provider_pending", None)
|
|
54460
54410
|
if codex_provider.session_choice(session.get("codex_provider")) == "custom":
|
|
54461
54411
|
return
|
|
54462
|
-
await refresh_codex_app_server_login()
|
|
54412
|
+
await refresh_codex_app_server_login(source="turn_admission")
|
|
54463
54413
|
session_id = str(session.get("id") or "")
|
|
54464
54414
|
manager = existing_codex_app_server_manager(session)
|
|
54465
54415
|
if getattr(manager, "_agentsdock_login_superseded", False) is not True:
|
|
@@ -54618,9 +54568,8 @@ async def codex_app_server_manager(sess: dict[str, Any] | None = None, *, allow_
|
|
|
54618
54568
|
selected = CODEX_PROVIDER_STORE.for_session(sess, include_key=True) if sess is not None else None
|
|
54619
54569
|
revision = selected["credential_id"] if selected else None
|
|
54620
54570
|
session_id = str((sess or {}).get("id") or "")
|
|
54621
|
-
#
|
|
54622
|
-
#
|
|
54623
|
-
# no caller can retain/create the old generation halfway through a toggle.
|
|
54571
|
+
# Manager lookup shares the provider reconfiguration barrier, including the
|
|
54572
|
+
# fast path, so a sign-in reload cannot race a retained/created generation.
|
|
54624
54573
|
async with CODEX_GOALS_CONFIG_LOCK:
|
|
54625
54574
|
ensure_provider_manager_factory_admission(codex=True)
|
|
54626
54575
|
manager = existing_codex_app_server_manager(sess) if sess is not None else None
|
|
@@ -78295,6 +78244,7 @@ async def require_agent_token(request: Request, call_next):
|
|
|
78295
78244
|
codex_goals_admin_route = request.url.path in {
|
|
78296
78245
|
"/api/admin/codex/goals", "/api/admin/codex/subagents",
|
|
78297
78246
|
"/api/admin/codex/auth", "/api/admin/codex/auth/api-key",
|
|
78247
|
+
"/api/admin/codex/auth/reload",
|
|
78298
78248
|
"/api/admin/codex/provider", "/api/admin/codex/provider/test",
|
|
78299
78249
|
"/api/admin/codex/provider/models",
|
|
78300
78250
|
"/api/admin/provider-connections/claude", "/api/admin/provider-connections/claude/check",
|
|
@@ -78483,7 +78433,7 @@ async def require_agent_token(request: Request, call_next):
|
|
|
78483
78433
|
if body_error is not None:
|
|
78484
78434
|
status_code, detail = body_error
|
|
78485
78435
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
|
78486
|
-
elif request.url.path
|
|
78436
|
+
elif request.url.path in {"/api/admin/codex/auth/api-key", "/api/admin/codex/auth/reload"} and request.method.upper() == "POST":
|
|
78487
78437
|
declared_size, transport_error = privileged_native_json_transport(
|
|
78488
78438
|
request, max_body_bytes=codex_auth.MAX_BODY_BYTES,
|
|
78489
78439
|
label="Codex authentication", require_content_length=True,
|
|
@@ -81302,15 +81252,11 @@ def codex_goals_admin_status() -> dict[str, Any]:
|
|
|
81302
81252
|
enabled = bool(CODEX_GOALS_ENABLED)
|
|
81303
81253
|
return {
|
|
81304
81254
|
"enabled": enabled,
|
|
81305
|
-
"configurable":
|
|
81255
|
+
"configurable": False,
|
|
81306
81256
|
"default_enabled": bool(CODEX_GOALS_DEFAULT_ENABLED),
|
|
81307
81257
|
"scope": "server",
|
|
81308
81258
|
"reconfiguring": bool(CODEX_GOALS_RECONFIGURING),
|
|
81309
|
-
"message":
|
|
81310
|
-
"Persistent Codex goals are enabled for this server."
|
|
81311
|
-
if enabled
|
|
81312
|
-
else "Persistent Codex goals are disabled for this server."
|
|
81313
|
-
),
|
|
81259
|
+
"message": "Persistent Codex goals are always available. Manage each goal in its chat.",
|
|
81314
81260
|
}
|
|
81315
81261
|
|
|
81316
81262
|
|
|
@@ -82134,36 +82080,14 @@ async def get_codex_goals_admin() -> dict[str, Any]:
|
|
|
82134
82080
|
async def put_codex_goals_admin(
|
|
82135
82081
|
req: CodexGoalsAdminRequest,
|
|
82136
82082
|
) -> dict[str, Any]:
|
|
82137
|
-
|
|
82138
|
-
|
|
82139
|
-
|
|
82140
|
-
|
|
82141
|
-
|
|
82142
|
-
|
|
82143
|
-
|
|
82144
|
-
|
|
82145
|
-
"paused_goal_count": 0,
|
|
82146
|
-
"fenced_goal_count": 0,
|
|
82147
|
-
}
|
|
82148
|
-
try:
|
|
82149
|
-
if not requested:
|
|
82150
|
-
transition = await pause_idle_codex_goals_before_disable()
|
|
82151
|
-
# The feature flag is a process-level Codex setting. Replacing the
|
|
82152
|
-
# shared, idle manager is the only way to make it authoritative for
|
|
82153
|
-
# every loaded and future thread.
|
|
82154
|
-
await close_codex_app_server_manager()
|
|
82155
|
-
atomic_update_json(
|
|
82156
|
-
CODEX_SETTINGS_FILE,
|
|
82157
|
-
{
|
|
82158
|
-
**settings,
|
|
82159
|
-
"goals_enabled": requested,
|
|
82160
|
-
"updated_at": update_utc_now(),
|
|
82161
|
-
},
|
|
82162
|
-
)
|
|
82163
|
-
CODEX_GOALS_ENABLED = requested
|
|
82164
|
-
finally:
|
|
82165
|
-
await release_codex_goals_reconfiguration()
|
|
82166
|
-
return {**codex_goals_admin_status(), **transition}
|
|
82083
|
+
# Preserve the authenticated legacy route without allowing an older client
|
|
82084
|
+
# to disable goals for every chat or restart the shared provider manager.
|
|
82085
|
+
if not req.enabled:
|
|
82086
|
+
raise HTTPException(
|
|
82087
|
+
status_code=409,
|
|
82088
|
+
detail="Persistent Codex goals cannot be disabled server-wide. Pause individual goals in their chats.",
|
|
82089
|
+
)
|
|
82090
|
+
return codex_goals_admin_status()
|
|
82167
82091
|
|
|
82168
82092
|
|
|
82169
82093
|
def require_native_admin_control(request: Request) -> None:
|
|
@@ -82187,7 +82111,7 @@ def require_native_admin_control(request: Request) -> None:
|
|
|
82187
82111
|
async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
|
|
82188
82112
|
"""Fence native authentication changes with the existing Codex admission barrier."""
|
|
82189
82113
|
if not mutate:
|
|
82190
|
-
await refresh_codex_app_server_login()
|
|
82114
|
+
await refresh_codex_app_server_login(source="account_status")
|
|
82191
82115
|
async with CODEX_AUTH_LOCK:
|
|
82192
82116
|
reserved = False
|
|
82193
82117
|
try:
|
|
@@ -82224,10 +82148,20 @@ async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
|
|
|
82224
82148
|
await release_codex_goals_reconfiguration()
|
|
82225
82149
|
|
|
82226
82150
|
|
|
82151
|
+
async def reload_codex_login() -> bool:
|
|
82152
|
+
logger.info("Codex login reload requested source=manual_login_reload")
|
|
82153
|
+
if not await refresh_codex_app_server_login(request_handoff=True, source="manual_login_reload"):
|
|
82154
|
+
logger.info("Codex login reload deferred source=manual_login_reload reason=unavailable_or_busy")
|
|
82155
|
+
raise HTTPException(409, "Codex sign-in cannot be reloaded now. Its credential store is unsupported or maintenance is active.")
|
|
82156
|
+
return any(getattr(manager, "_agentsdock_login_superseded", False) is True
|
|
82157
|
+
for manager in codex_app_server_managers())
|
|
82158
|
+
|
|
82159
|
+
|
|
82227
82160
|
app.include_router(codex_auth.create_router(
|
|
82228
82161
|
authorize=require_native_admin_control,
|
|
82229
82162
|
operation=codex_auth_operation,
|
|
82230
82163
|
available=lambda: CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC,
|
|
82164
|
+
reload_login=reload_codex_login,
|
|
82231
82165
|
))
|
|
82232
82166
|
|
|
82233
82167
|
|
|
@@ -85787,7 +85721,7 @@ async def host_diagnostics(limit: int = 40) -> dict[str, Any]:
|
|
|
85787
85721
|
async def runtime_catalog(refresh: bool = False) -> dict[str, Any]:
|
|
85788
85722
|
if refresh:
|
|
85789
85723
|
await refresh_codex_app_server_binary(force=True)
|
|
85790
|
-
await refresh_codex_app_server_login()
|
|
85724
|
+
await refresh_codex_app_server_login(source="catalog_refresh")
|
|
85791
85725
|
return await asyncio.to_thread(discover_runtime_catalog, force_runtime_probe=refresh)
|
|
85792
85726
|
|
|
85793
85727
|
|
|
@@ -85908,12 +85842,13 @@ async def tail_session_process_log(session_id: str, path: str, lines: int = 200)
|
|
|
85908
85842
|
|
|
85909
85843
|
|
|
85910
85844
|
@app.get("/api/sessions")
|
|
85911
|
-
async def list_sessions(summary: bool = False) -> dict[str, Any]:
|
|
85845
|
+
async def list_sessions(summary: bool = False, placeholder_client_id: str = "") -> dict[str, Any]:
|
|
85912
85846
|
await STORE.ensure_sort_orders()
|
|
85913
85847
|
sessions = [
|
|
85914
85848
|
public_session(session, summary=summary)
|
|
85915
85849
|
for session in sorted_sessions(list(STORE.sessions.values()))
|
|
85916
85850
|
if not session.get("_fork_initializing")
|
|
85851
|
+
and not empty_chat_dismissed_for(session, placeholder_client_id)
|
|
85917
85852
|
]
|
|
85918
85853
|
return {"sessions": sessions}
|
|
85919
85854
|
|
|
@@ -89520,6 +89455,74 @@ async def ensure_discardable_empty_session(session_id: str, expected_updated_at:
|
|
|
89520
89455
|
raise HTTPException(status_code=409, detail="Chat has content or resources; it was preserved.")
|
|
89521
89456
|
|
|
89522
89457
|
|
|
89458
|
+
def empty_chat_revision(session: dict[str, Any]) -> str:
|
|
89459
|
+
# Use durable content/settings, not public runtime projections, read cursors
|
|
89460
|
+
# or sort order. Reconnecting, reading or normalizing runtime metadata on
|
|
89461
|
+
# startup must not resurrect an unchanged blank row.
|
|
89462
|
+
revision = {key: session.get(key) for key in (
|
|
89463
|
+
"created_at", "updated_at", "title", "folder", "cwd", "backend",
|
|
89464
|
+
"model", "effort", "system_prompt", "_title_source",
|
|
89465
|
+
"codex_provider", "provider_connection", "parent_id",
|
|
89466
|
+
"codex_goal", "claude_goal",
|
|
89467
|
+
)}
|
|
89468
|
+
revision["latest_event_seq"] = int(session.get("latest_event_seq") or 0)
|
|
89469
|
+
for key in ("backend_locked", "pinned", "archived", "manual_unread"):
|
|
89470
|
+
revision[key] = bool(session.get(key))
|
|
89471
|
+
return hashlib.sha256(json.dumps(revision, sort_keys=True,
|
|
89472
|
+
separators=(",", ":")).encode()).hexdigest()
|
|
89473
|
+
|
|
89474
|
+
|
|
89475
|
+
def empty_chat_dismissed_for(session: dict[str, Any], client_id: str) -> bool:
|
|
89476
|
+
if not client_id or session_backend_locked(session):
|
|
89477
|
+
return False
|
|
89478
|
+
state = session.get("_empty_chat_cleanup") or {}
|
|
89479
|
+
return (isinstance(state, dict) and state.get("version") == 1
|
|
89480
|
+
and isinstance(state.get("dismissed_by"), dict)
|
|
89481
|
+
and state["dismissed_by"].get(client_id) == empty_chat_revision(session))
|
|
89482
|
+
|
|
89483
|
+
|
|
89484
|
+
@app.post("/api/sessions/{session_id}/dismiss-empty")
|
|
89485
|
+
async def dismiss_empty_session(session_id: str, expected_updated_at: str,
|
|
89486
|
+
placeholder_client_id: str) -> dict[str, Any]:
|
|
89487
|
+
"""Remove an unused row from one desktop's list without deleting its identity.
|
|
89488
|
+
|
|
89489
|
+
Another/offline desktop may own an unsent draft. Its list is unaffected;
|
|
89490
|
+
subsequent real changes also invalidate this desktop's dismissal. No draft
|
|
89491
|
+
content leaves a desktop, and no native history/process is touched.
|
|
89492
|
+
"""
|
|
89493
|
+
if not re.fullmatch(r"[a-zA-Z0-9_-]{16,80}", placeholder_client_id):
|
|
89494
|
+
raise HTTPException(400, "Invalid placeholder client identity")
|
|
89495
|
+
await wait_for_queue_recovery_admission()
|
|
89496
|
+
async with session_lifecycle_lock(session_id):
|
|
89497
|
+
ensure_session_not_initializing(session_id)
|
|
89498
|
+
if session_id in DELETING_SESSIONS or session_id in DELETED_SESSION_TOMBSTONES:
|
|
89499
|
+
raise HTTPException(409, "Chat deletion is already in progress")
|
|
89500
|
+
# Existing admission, uploads, jobs and terminal routes use this fence.
|
|
89501
|
+
DELETING_SESSIONS.add(session_id)
|
|
89502
|
+
try:
|
|
89503
|
+
await ensure_discardable_empty_session(session_id, expected_updated_at)
|
|
89504
|
+
async with STORE._lock:
|
|
89505
|
+
session = STORE.sessions[session_id]
|
|
89506
|
+
previous = session.get("_empty_chat_cleanup")
|
|
89507
|
+
state = previous if isinstance(previous, dict) else {}
|
|
89508
|
+
dismissed = dict(state.get("dismissed_by") or {})
|
|
89509
|
+
if placeholder_client_id not in dismissed and len(dismissed) >= 256:
|
|
89510
|
+
raise HTTPException(409, "Too many desktop cleanup identities; chat preserved")
|
|
89511
|
+
dismissed[placeholder_client_id] = empty_chat_revision(session)
|
|
89512
|
+
session["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": dismissed}
|
|
89513
|
+
try:
|
|
89514
|
+
await STORE.save()
|
|
89515
|
+
except BaseException:
|
|
89516
|
+
if previous is None:
|
|
89517
|
+
session.pop("_empty_chat_cleanup", None)
|
|
89518
|
+
else:
|
|
89519
|
+
session["_empty_chat_cleanup"] = previous
|
|
89520
|
+
raise
|
|
89521
|
+
return {"ok": True, "dismissed": True}
|
|
89522
|
+
finally:
|
|
89523
|
+
DELETING_SESSIONS.discard(session_id)
|
|
89524
|
+
|
|
89525
|
+
|
|
89523
89526
|
@app.post("/api/sessions/{session_id}/discard-empty")
|
|
89524
89527
|
async def discard_empty_session(session_id: str, expected_updated_at: str) -> dict[str, Any]:
|
|
89525
89528
|
# A separate route makes old servers fail safely with 404, rather than
|
|
@@ -90024,19 +90027,20 @@ def claude_imported_fork_boundary(
|
|
|
90024
90027
|
digest = hashlib.sha256()
|
|
90025
90028
|
last_message: dict[str, Any] = {}
|
|
90026
90029
|
with path.open("rb") as stream:
|
|
90027
|
-
|
|
90028
|
-
|
|
90029
|
-
|
|
90030
|
-
raw
|
|
90031
|
-
|
|
90032
|
-
|
|
90033
|
-
remaining -= len(raw)
|
|
90034
|
-
digest.update(raw)
|
|
90035
|
-
if not raw.strip():
|
|
90030
|
+
original = os.fstat(stream.fileno())
|
|
90031
|
+
for record, offset, raw in jsonl_records(stream, end=remaining,
|
|
90032
|
+
max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES):
|
|
90033
|
+
raw.update_hash(digest)
|
|
90034
|
+
remaining = int(cursor["source_offset"]) - offset
|
|
90035
|
+
if raw.blank:
|
|
90036
90036
|
continue
|
|
90037
|
-
record
|
|
90038
|
-
|
|
90037
|
+
if record is None:
|
|
90038
|
+
raise ValueError("imported history checkpoint has a malformed transcript record")
|
|
90039
|
+
if record.get("type") in {"user", "assistant"} and not record.get("isSidechain"):
|
|
90039
90040
|
last_message = record
|
|
90041
|
+
current = os.fstat(stream.fileno())
|
|
90042
|
+
if (original.st_dev, original.st_ino, original.st_size, original.st_mtime_ns) != (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns):
|
|
90043
|
+
raise ValueError("imported history checkpoint changed during reading")
|
|
90040
90044
|
if remaining or not hmac.compare_digest(digest.hexdigest(), cursor["source_digest"]):
|
|
90041
90045
|
raise ValueError("imported history checkpoint no longer matches its native prefix")
|
|
90042
90046
|
message = last_message.get("message")
|