2340 lines
97 KiB
Python
2340 lines
97 KiB
Python
#!/usr/bin/env python3
|
|
"""Codex app-server transport and persistent-thread lifecycle primitives.
|
|
|
|
Root runs and detached workers each own one client through this boundary. MMO
|
|
callers control workers through the authenticated Agent-MCP kernel rather than
|
|
connecting to a worker's private app-server process directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import contextlib
|
|
import datetime as dt
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import random
|
|
import re
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from mmo_util import (
|
|
append_jsonl,
|
|
atomic_write_text,
|
|
bounded_text,
|
|
package_version,
|
|
strict_json_loads,
|
|
terminate_process_group,
|
|
utc_now,
|
|
)
|
|
from mmo_version import APP_SERVER_PROTOCOL_CODEX_VERSION
|
|
|
|
|
|
class AppServerError(RuntimeError):
|
|
"""Codex app-server transport or protocol failure."""
|
|
|
|
|
|
class ControlDeliveryUnknown(AppServerError):
|
|
"""A worker control request may have been applied before transport failed."""
|
|
|
|
|
|
class ControlRequestRejected(AppServerError):
|
|
"""A worker explicitly rejected a delivered control request."""
|
|
|
|
|
|
# Initialization is local, while thread lifecycle requests may start or resume
|
|
# required MCP servers. Keep the latter generous enough for slow external
|
|
# integrations without turning individual model turns into wall-clock timers.
|
|
APP_SERVER_INITIALIZE_TIMEOUT_SECONDS = 60.0
|
|
APP_SERVER_LIFECYCLE_TIMEOUT_SECONDS = 1200.0
|
|
APP_SERVER_RECOVERY_DELAYS_SECONDS = (1.0, 5.0, 15.0)
|
|
APP_SERVER_GOAL_OBJECTIVE_MAX_CHARS = 4000
|
|
APP_SERVER_DYNAMIC_TOOL_TIMEOUT_SECONDS = APP_SERVER_LIFECYCLE_TIMEOUT_SECONDS + 60.0
|
|
PARTIAL_EVENT_WINDOW_BYTES = 32 * 1024 * 1024
|
|
APP_SERVER_PROTOCOL_FILE_COUNT = 401
|
|
APP_SERVER_PROTOCOL_SHA256 = "fcfeaf23728b96ab73916a21302eb7a16629e67ee99f7ee47b60fad6b6e5ee1a"
|
|
APP_SERVER_OVERLOAD_RETRY_DELAYS_SECONDS = (0.1, 0.2, 0.4, 0.8)
|
|
MAX_WEBSOCKET_MESSAGE_BYTES = 64 * 1024 * 1024
|
|
MAX_CONTROL_MESSAGE_BYTES = 4 * 1024 * 1024
|
|
APPROVAL_REQUEST_METHODS = frozenset(
|
|
{
|
|
"item/commandExecution/requestApproval",
|
|
"item/fileChange/requestApproval",
|
|
"item/permissions/requestApproval",
|
|
"applyPatchApproval",
|
|
"execCommandApproval",
|
|
}
|
|
)
|
|
PENDING_SERVER_REQUEST_METHODS = frozenset(
|
|
{
|
|
"item/tool/requestUserInput",
|
|
"mcpServer/elicitation/request",
|
|
*APPROVAL_REQUEST_METHODS,
|
|
}
|
|
)
|
|
UNSUPPORTED_SERVER_REQUEST_METHODS = frozenset(
|
|
{
|
|
"account/chatgptAuthTokens/refresh",
|
|
"attestation/generate",
|
|
}
|
|
)
|
|
|
|
_RESPONSES_TOOL_NAME_PATTERN = re.compile(r"[^a-zA-Z0-9_-]+")
|
|
_DYNAMIC_MCP_TOOL_PREFIX = "mmo_mcp__"
|
|
|
|
|
|
def _responses_tool_name(value: str) -> str:
|
|
"""Return a non-empty Responses-compatible identifier component."""
|
|
|
|
normalized = _RESPONSES_TOOL_NAME_PATTERN.sub("_", value).strip("_")
|
|
return normalized or "tool"
|
|
|
|
|
|
def _flat_mcp_dynamic_tool_name(server: str, tool: str, *, salt: str = "") -> str:
|
|
"""Build the flat compatibility name for one namespaced MCP function."""
|
|
|
|
# Codex reserves the native ``mcp__`` namespace and rejects dynamic tool
|
|
# declarations using it. Keep the shim visibly MMO-owned while preserving
|
|
# the server/tool split in the model-facing name.
|
|
base = f"{_DYNAMIC_MCP_TOOL_PREFIX}{_responses_tool_name(server)}__{_responses_tool_name(tool)}"
|
|
if len(base) <= 128 and not salt:
|
|
return base
|
|
digest = hashlib.sha256(f"{server}\0{tool}\0{salt}".encode()).hexdigest()[:12]
|
|
suffix = f"__{digest}"
|
|
return base[: 128 - len(suffix)].rstrip("_") + suffix
|
|
|
|
|
|
def _dynamic_tool_content_items(result: Mapping[str, Any]) -> list[dict[str, Any]]:
|
|
"""Translate one MCP call result into Codex dynamic-tool content items."""
|
|
|
|
items: list[dict[str, Any]] = []
|
|
decoded_text_values: list[Any] = []
|
|
content = result.get("content")
|
|
if isinstance(content, list):
|
|
for block in content:
|
|
if not isinstance(block, Mapping):
|
|
items.append(
|
|
{
|
|
"type": "inputText",
|
|
"text": json.dumps(block, ensure_ascii=False, allow_nan=False),
|
|
}
|
|
)
|
|
continue
|
|
block_type = block.get("type")
|
|
if block_type == "text" and isinstance(block.get("text"), str):
|
|
items.append({"type": "inputText", "text": block["text"]})
|
|
with contextlib.suppress(json.JSONDecodeError, ValueError):
|
|
decoded_text_values.append(strict_json_loads(block["text"]))
|
|
elif (
|
|
block_type == "image"
|
|
and isinstance(block.get("data"), str)
|
|
and isinstance(block.get("mimeType"), str)
|
|
):
|
|
items.append(
|
|
{
|
|
"type": "inputImage",
|
|
"imageUrl": f"data:{block['mimeType']};base64,{block['data']}",
|
|
}
|
|
)
|
|
elif (
|
|
block_type == "audio"
|
|
and isinstance(block.get("data"), str)
|
|
and isinstance(block.get("mimeType"), str)
|
|
):
|
|
items.append(
|
|
{
|
|
"type": "inputAudio",
|
|
"audioUrl": f"data:{block['mimeType']};base64,{block['data']}",
|
|
}
|
|
)
|
|
else:
|
|
items.append(
|
|
{
|
|
"type": "inputText",
|
|
"text": json.dumps(
|
|
dict(block),
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
),
|
|
}
|
|
)
|
|
structured = result.get("structuredContent")
|
|
structured_already_present = structured in decoded_text_values
|
|
if isinstance(structured, Mapping) and len(structured) == 1:
|
|
structured_already_present = (
|
|
structured_already_present or next(iter(structured.values())) in decoded_text_values
|
|
)
|
|
if structured is not None and not structured_already_present:
|
|
items.append(
|
|
{
|
|
"type": "inputText",
|
|
"text": "structuredContent="
|
|
+ json.dumps(
|
|
structured,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
),
|
|
}
|
|
)
|
|
if not items:
|
|
items.append({"type": "inputText", "text": ""})
|
|
return items
|
|
|
|
|
|
_PROVIDER_LIMIT_PATTERN = re.compile(
|
|
r"(?:usage\s+limit|limit\s+(?:reached|exhausted)|quota|rate\s+limit)",
|
|
re.IGNORECASE,
|
|
)
|
|
_PROVIDER_RESET_PATTERN = re.compile(
|
|
r"(?:limit\s+will\s+reset|reset)\s+at\s+"
|
|
r"(?P<value>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})"
|
|
r"(?:\s*(?P<zone>Z|UTC|[+-]\d{2}:?\d{2}))?",
|
|
re.IGNORECASE,
|
|
)
|
|
_RETRYABLE_CODEX_TRANSPORT_ERRORS = frozenset(
|
|
{
|
|
"serverOverloaded",
|
|
"internalServerError",
|
|
"httpConnectionFailed",
|
|
"responseStreamConnectionFailed",
|
|
"responseStreamDisconnected",
|
|
"responseTooManyFailedAttempts",
|
|
}
|
|
)
|
|
|
|
|
|
def normalize_turn_failure(turn: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
"""Translate one authoritative failed turn into durable lifecycle evidence.
|
|
|
|
Codex providers do not currently expose a common quota error enum. Preserve
|
|
the complete error object, prefer its structured fields, and use the human
|
|
message only to distinguish a provider limit from a generic failed turn.
|
|
"""
|
|
|
|
if turn.get("status") != "failed":
|
|
return None
|
|
raw_error = turn.get("error")
|
|
error = dict(raw_error) if isinstance(raw_error, Mapping) else {}
|
|
message_value = error.get("message") if error else raw_error
|
|
message = str(message_value or "app-server turn failed")
|
|
details = error.get("additionalDetails")
|
|
searchable = "\n".join(
|
|
value for value in (message, str(details) if details is not None else "") if value
|
|
)
|
|
codex_error_info = error.get("codexErrorInfo")
|
|
codex_error_kind = (
|
|
codex_error_info
|
|
if isinstance(codex_error_info, str)
|
|
else next(iter(codex_error_info), None)
|
|
if isinstance(codex_error_info, Mapping)
|
|
else None
|
|
)
|
|
kind = "turn_failed"
|
|
retryable = False
|
|
if codex_error_kind == "usageLimitExceeded" or _PROVIDER_LIMIT_PATTERN.search(searchable):
|
|
kind = "provider_usage_limited"
|
|
retryable = True
|
|
elif "failed to parse tool call arguments" in searchable.lower():
|
|
kind = "malformed_tool_arguments"
|
|
retryable = True
|
|
elif (
|
|
"stream disconnected" in searchable.lower()
|
|
or codex_error_kind in _RETRYABLE_CODEX_TRANSPORT_ERRORS
|
|
):
|
|
kind = "provider_transport"
|
|
retryable = True
|
|
retry_at: str | None = None
|
|
retry_at_raw: str | None = None
|
|
retry_at_timezone: str | None = None
|
|
match = _PROVIDER_RESET_PATTERN.search(searchable)
|
|
if match is not None:
|
|
value = match.group("value")
|
|
zone = match.group("zone")
|
|
retry_at_raw = value + (f" {zone}" if zone else "")
|
|
if zone is not None:
|
|
retry_at_timezone = zone
|
|
normalized_zone = "+00:00" if zone.upper() in {"Z", "UTC"} else zone
|
|
if len(normalized_zone) == 5 and normalized_zone[3] != ":":
|
|
normalized_zone = normalized_zone[:3] + ":" + normalized_zone[3:]
|
|
with contextlib.suppress(ValueError):
|
|
retry_at = dt.datetime.fromisoformat(
|
|
value.replace(" ", "T") + normalized_zone
|
|
).isoformat()
|
|
result: dict[str, Any] = {
|
|
"kind": kind,
|
|
"source": "turn/completed",
|
|
"turn_id": turn.get("id"),
|
|
"turn_status": "failed",
|
|
"message": message,
|
|
"codex_error_info": codex_error_info,
|
|
"raw_error": raw_error,
|
|
"retryable": retryable,
|
|
"observed_at": utc_now(),
|
|
}
|
|
if retry_at_raw is not None:
|
|
result["retry_at_raw"] = retry_at_raw
|
|
if retry_at is not None and retry_at_timezone is not None:
|
|
result["retry_at"] = retry_at
|
|
result["retry_at_timezone"] = retry_at_timezone
|
|
return result
|
|
|
|
|
|
def app_server_message_thread_id(message: Mapping[str, Any]) -> str | None:
|
|
"""Return the thread identity carried by a notification or server request."""
|
|
|
|
raw_params = message.get("params")
|
|
if not isinstance(raw_params, Mapping):
|
|
return None
|
|
for key in ("threadId", "conversationId"):
|
|
value = raw_params.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
goal = raw_params.get("goal")
|
|
if isinstance(goal, Mapping) and isinstance(goal.get("threadId"), str):
|
|
return str(goal["threadId"])
|
|
thread = raw_params.get("thread")
|
|
if isinstance(thread, Mapping) and isinstance(thread.get("id"), str):
|
|
return str(thread["id"])
|
|
return None
|
|
|
|
|
|
def app_server_socket_path(identity: str) -> Path:
|
|
"""Return a stable, private, portable AF_UNIX path for one logical host."""
|
|
|
|
runtime_root = os.environ.get("XDG_RUNTIME_DIR")
|
|
if runtime_root:
|
|
base = Path(runtime_root).expanduser().resolve() / "codex-mmo"
|
|
else:
|
|
owner = hashlib.sha256(f"{os.getuid()}:{Path.home().resolve()}".encode()).hexdigest()[:12]
|
|
base = Path(tempfile.gettempdir()).resolve() / f"codex-mmo-{owner}"
|
|
if base.exists() and (base.is_symlink() or not base.is_dir()):
|
|
raise AppServerError(f"app-server socket root is unsafe: {base}")
|
|
base.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
with contextlib.suppress(OSError):
|
|
base.chmod(0o700)
|
|
name = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:32] + ".sock"
|
|
path = base / name
|
|
if len(os.fsencode(path)) >= 104:
|
|
raise AppServerError("app-server runtime directory is too long for AF_UNIX")
|
|
return path
|
|
|
|
|
|
def app_server_listen_command(
|
|
binary: str,
|
|
command_flags: Sequence[str],
|
|
socket_path: Path,
|
|
) -> list[str]:
|
|
return [
|
|
binary,
|
|
*command_flags,
|
|
"app-server",
|
|
"--listen",
|
|
f"unix://{socket_path}",
|
|
]
|
|
|
|
|
|
def app_server_protocol_status(binary: str | None) -> dict[str, Any]:
|
|
"""Require the reviewed Codex 0.149 experimental app-server contract exactly."""
|
|
|
|
if not binary:
|
|
return {
|
|
"passed": False,
|
|
"expected_codex_version": APP_SERVER_PROTOCOL_CODEX_VERSION,
|
|
"expected_schema_sha256": APP_SERVER_PROTOCOL_SHA256,
|
|
"error": "Codex binary not found",
|
|
}
|
|
|
|
try:
|
|
version_result = subprocess.run(
|
|
[binary, "--version"],
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
version_text = version_result.stdout.strip()
|
|
observed_version = version_text.removeprefix("codex-cli ").strip()
|
|
with tempfile.TemporaryDirectory(prefix="codex-mmo-app-schema-") as temporary:
|
|
result = subprocess.run(
|
|
[
|
|
binary,
|
|
"app-server",
|
|
"generate-json-schema",
|
|
"--experimental",
|
|
"--out",
|
|
temporary,
|
|
],
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=30,
|
|
check=False,
|
|
)
|
|
schema_root = Path(temporary)
|
|
files = sorted(path for path in schema_root.rglob("*") if path.is_file())
|
|
digest = hashlib.sha256()
|
|
for path in files:
|
|
digest.update(path.relative_to(schema_root).as_posix().encode("utf-8"))
|
|
digest.update(b"\0")
|
|
digest.update(path.read_bytes())
|
|
observed_sha256 = digest.hexdigest()
|
|
version_matches = (
|
|
version_result.returncode == 0 and observed_version == APP_SERVER_PROTOCOL_CODEX_VERSION
|
|
)
|
|
schema_matches = (
|
|
result.returncode == 0
|
|
and len(files) == APP_SERVER_PROTOCOL_FILE_COUNT
|
|
and observed_sha256 == APP_SERVER_PROTOCOL_SHA256
|
|
)
|
|
return {
|
|
"passed": version_matches and schema_matches,
|
|
"expected_codex_version": APP_SERVER_PROTOCOL_CODEX_VERSION,
|
|
"observed_codex_version": observed_version,
|
|
"expected_schema_files": APP_SERVER_PROTOCOL_FILE_COUNT,
|
|
"observed_schema_files": len(files),
|
|
"expected_schema_sha256": APP_SERVER_PROTOCOL_SHA256,
|
|
"observed_schema_sha256": observed_sha256,
|
|
"version_matches": version_matches,
|
|
"schema_matches": schema_matches,
|
|
"schema_exit_code": result.returncode,
|
|
"stderr": (version_result.stderr + result.stderr)[-2000:],
|
|
}
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return {
|
|
"passed": False,
|
|
"expected_codex_version": APP_SERVER_PROTOCOL_CODEX_VERSION,
|
|
"expected_schema_sha256": APP_SERVER_PROTOCOL_SHA256,
|
|
"error": f"{type(exc).__name__}: {exc}",
|
|
}
|
|
|
|
|
|
def require_app_server_codex_version(binary: str) -> None:
|
|
"""Reject session admission unless the executable is the reviewed Codex release."""
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[binary, "--version"],
|
|
text=True,
|
|
capture_output=True,
|
|
timeout=10,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
raise AppServerError(f"unable to verify Codex app-server version: {exc}") from exc
|
|
observed = result.stdout.strip().removeprefix("codex-cli ").strip()
|
|
if result.returncode != 0 or observed != APP_SERVER_PROTOCOL_CODEX_VERSION:
|
|
raise AppServerError(
|
|
"Codex app-server protocol version mismatch: expected "
|
|
f"{APP_SERVER_PROTOCOL_CODEX_VERSION}, observed {observed or 'unavailable'}"
|
|
)
|
|
|
|
|
|
def _exact_keys(
|
|
value: Mapping[str, Any], *, required: set[str], allowed: set[str], label: str
|
|
) -> None:
|
|
missing = sorted(required - set(value))
|
|
unexpected = sorted(set(value) - allowed)
|
|
if missing or unexpected:
|
|
details: list[str] = []
|
|
if missing:
|
|
details.append("missing " + ", ".join(missing))
|
|
if unexpected:
|
|
details.append("unexpected " + ", ".join(unexpected))
|
|
raise ValueError(f"{label} has invalid fields: {'; '.join(details)}")
|
|
|
|
|
|
def _network_policy_amendment(value: Any, label: str) -> None:
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError(f"{label} must be an object")
|
|
_exact_keys(value, required={"action", "host"}, allowed={"action", "host"}, label=label)
|
|
if value["action"] not in {"allow", "deny"} or not isinstance(value["host"], str):
|
|
raise ValueError(f"{label} must contain an allow/deny action and string host")
|
|
|
|
|
|
def _command_approval_decision(value: Any) -> None:
|
|
if isinstance(value, str):
|
|
if value not in {"accept", "acceptForSession", "decline", "cancel"}:
|
|
raise ValueError("command approval decision is invalid")
|
|
return
|
|
if not isinstance(value, Mapping) or len(value) != 1:
|
|
raise ValueError("command approval decision must be a supported string or object")
|
|
if "acceptWithExecpolicyAmendment" in value:
|
|
amendment = value["acceptWithExecpolicyAmendment"]
|
|
if not isinstance(amendment, Mapping):
|
|
raise ValueError("exec-policy amendment must be an object")
|
|
_exact_keys(
|
|
amendment,
|
|
required={"execpolicy_amendment"},
|
|
allowed={"execpolicy_amendment"},
|
|
label="exec-policy amendment",
|
|
)
|
|
rules = amendment["execpolicy_amendment"]
|
|
if not isinstance(rules, list) or not all(isinstance(item, str) for item in rules):
|
|
raise ValueError("execpolicy_amendment must be an array of strings")
|
|
return
|
|
if "applyNetworkPolicyAmendment" in value:
|
|
amendment = value["applyNetworkPolicyAmendment"]
|
|
if not isinstance(amendment, Mapping):
|
|
raise ValueError("network-policy amendment wrapper must be an object")
|
|
_exact_keys(
|
|
amendment,
|
|
required={"network_policy_amendment"},
|
|
allowed={"network_policy_amendment"},
|
|
label="network-policy amendment wrapper",
|
|
)
|
|
_network_policy_amendment(amendment["network_policy_amendment"], "network-policy amendment")
|
|
return
|
|
raise ValueError("command approval decision object is invalid")
|
|
|
|
|
|
def _legacy_approval_decision(value: Any) -> None:
|
|
if isinstance(value, str):
|
|
if value not in {
|
|
"approved",
|
|
"approved_for_session",
|
|
"approved_mcp_policy_amendment",
|
|
"timed_out",
|
|
"abort",
|
|
}:
|
|
raise ValueError("legacy approval decision is invalid")
|
|
return
|
|
if not isinstance(value, Mapping) or len(value) != 1:
|
|
raise ValueError("legacy approval decision must be a supported string or object")
|
|
if "approved_execpolicy_amendment" in value:
|
|
amendment = value["approved_execpolicy_amendment"]
|
|
if not isinstance(amendment, Mapping):
|
|
raise ValueError("approved exec-policy amendment must be an object")
|
|
_exact_keys(
|
|
amendment,
|
|
required={"proposed_execpolicy_amendment"},
|
|
allowed={"proposed_execpolicy_amendment"},
|
|
label="approved exec-policy amendment",
|
|
)
|
|
rules = amendment["proposed_execpolicy_amendment"]
|
|
if not isinstance(rules, list) or not all(isinstance(item, str) for item in rules):
|
|
raise ValueError("proposed_execpolicy_amendment must be an array of strings")
|
|
return
|
|
if "network_policy_amendment" in value:
|
|
wrapper = value["network_policy_amendment"]
|
|
if not isinstance(wrapper, Mapping):
|
|
raise ValueError("legacy network-policy amendment wrapper must be an object")
|
|
_exact_keys(
|
|
wrapper,
|
|
required={"network_policy_amendment"},
|
|
allowed={"network_policy_amendment"},
|
|
label="legacy network-policy amendment wrapper",
|
|
)
|
|
_network_policy_amendment(
|
|
wrapper["network_policy_amendment"], "legacy network-policy amendment"
|
|
)
|
|
return
|
|
if "denied" in value:
|
|
denied = value["denied"]
|
|
if not isinstance(denied, Mapping):
|
|
raise ValueError("denied approval decision must be an object")
|
|
_exact_keys(
|
|
denied,
|
|
required={"rejection"},
|
|
allowed={"rejection"},
|
|
label="denied approval decision",
|
|
)
|
|
if not isinstance(denied["rejection"], str):
|
|
raise ValueError("approval rejection must be a string")
|
|
return
|
|
raise ValueError("legacy approval decision object is invalid")
|
|
|
|
|
|
def _permission_path(value: Any) -> None:
|
|
if not isinstance(value, Mapping) or not isinstance(value.get("type"), str):
|
|
raise ValueError("permission path must be a tagged object")
|
|
path_type = value["type"]
|
|
if path_type == "path":
|
|
required, allowed, field = {"type", "path"}, {"type", "path"}, "path"
|
|
elif path_type == "glob_pattern":
|
|
required, allowed, field = {"type", "pattern"}, {"type", "pattern"}, "pattern"
|
|
elif path_type == "special":
|
|
required, allowed, field = {"type", "value"}, {"type", "value"}, "value"
|
|
else:
|
|
raise ValueError("permission path type is invalid")
|
|
_exact_keys(value, required=required, allowed=allowed, label="permission path")
|
|
if path_type != "special":
|
|
if not isinstance(value[field], str):
|
|
raise ValueError(f"permission path {field} must be a string")
|
|
return
|
|
special = value[field]
|
|
if not isinstance(special, Mapping) or not isinstance(special.get("kind"), str):
|
|
raise ValueError("special permission path must be a tagged object")
|
|
kind = special["kind"]
|
|
if kind in {"root", "minimal", "tmpdir", "slash_tmp"}:
|
|
_exact_keys(special, required={"kind"}, allowed={"kind"}, label="special path")
|
|
elif kind == "project_roots":
|
|
_exact_keys(
|
|
special,
|
|
required={"kind"},
|
|
allowed={"kind", "subpath"},
|
|
label="project-roots special path",
|
|
)
|
|
if (
|
|
"subpath" in special
|
|
and special["subpath"] is not None
|
|
and not isinstance(special["subpath"], str)
|
|
):
|
|
raise ValueError("project-roots subpath must be a string or null")
|
|
elif kind == "unknown":
|
|
_exact_keys(
|
|
special,
|
|
required={"kind", "path"},
|
|
allowed={"kind", "path", "subpath"},
|
|
label="unknown special path",
|
|
)
|
|
if not isinstance(special["path"], str) or (
|
|
"subpath" in special
|
|
and special["subpath"] is not None
|
|
and not isinstance(special["subpath"], str)
|
|
):
|
|
raise ValueError("unknown special path values are invalid")
|
|
else:
|
|
raise ValueError("special permission path kind is invalid")
|
|
|
|
|
|
def _permission_profile(value: Any) -> None:
|
|
if not isinstance(value, Mapping):
|
|
raise ValueError("permissions must be an object")
|
|
_exact_keys(
|
|
value,
|
|
required=set(),
|
|
allowed={"fileSystem", "network"},
|
|
label="permissions",
|
|
)
|
|
filesystem = value.get("fileSystem")
|
|
if filesystem is not None:
|
|
if not isinstance(filesystem, Mapping):
|
|
raise ValueError("permissions.fileSystem must be an object or null")
|
|
_exact_keys(
|
|
filesystem,
|
|
required=set(),
|
|
allowed={"entries", "globScanMaxDepth", "read", "write"},
|
|
label="permissions.fileSystem",
|
|
)
|
|
entries = filesystem.get("entries")
|
|
if entries is not None:
|
|
if not isinstance(entries, list):
|
|
raise ValueError("permissions.fileSystem.entries must be an array or null")
|
|
for entry in entries:
|
|
if not isinstance(entry, Mapping):
|
|
raise ValueError("filesystem permission entry must be an object")
|
|
_exact_keys(
|
|
entry,
|
|
required={"access", "path"},
|
|
allowed={"access", "path"},
|
|
label="filesystem permission entry",
|
|
)
|
|
if entry["access"] not in {"read", "write", "deny"}:
|
|
raise ValueError("filesystem permission access is invalid")
|
|
_permission_path(entry["path"])
|
|
depth = filesystem.get("globScanMaxDepth")
|
|
if depth is not None and (
|
|
not isinstance(depth, int) or isinstance(depth, bool) or depth < 1
|
|
):
|
|
raise ValueError("globScanMaxDepth must be a positive integer or null")
|
|
for field in ("read", "write"):
|
|
paths = filesystem.get(field)
|
|
if paths is not None and (
|
|
not isinstance(paths, list) or not all(isinstance(item, str) for item in paths)
|
|
):
|
|
raise ValueError(f"permissions.fileSystem.{field} must be strings or null")
|
|
network = value.get("network")
|
|
if network is not None:
|
|
if not isinstance(network, Mapping):
|
|
raise ValueError("permissions.network must be an object or null")
|
|
_exact_keys(
|
|
network,
|
|
required=set(),
|
|
allowed={"enabled"},
|
|
label="permissions.network",
|
|
)
|
|
if network.get("enabled") is not None and not isinstance(network["enabled"], bool):
|
|
raise ValueError("permissions.network.enabled must be a boolean or null")
|
|
|
|
|
|
def validate_server_request_response(method: str, response: Mapping[str, Any]) -> None:
|
|
"""Validate one controller response against Codex app-server v2's method shape."""
|
|
|
|
if method == "item/tool/requestUserInput":
|
|
_exact_keys(response, required={"answers"}, allowed={"answers"}, label=method)
|
|
answers = response["answers"]
|
|
if not isinstance(answers, Mapping) or not all(isinstance(key, str) for key in answers):
|
|
raise ValueError("user-input answers must map string question IDs to answer objects")
|
|
for answer in answers.values():
|
|
if not isinstance(answer, Mapping):
|
|
raise ValueError("each user-input answer must be an object")
|
|
_exact_keys(
|
|
answer,
|
|
required={"answers"},
|
|
allowed={"answers"},
|
|
label="user-input answer",
|
|
)
|
|
values = answer["answers"]
|
|
if not isinstance(values, list) or not all(isinstance(item, str) for item in values):
|
|
raise ValueError("each user-input answer must contain an array of strings")
|
|
return
|
|
if method == "mcpServer/elicitation/request":
|
|
_exact_keys(
|
|
response,
|
|
required={"action"},
|
|
allowed={"action", "content", "_meta"},
|
|
label=method,
|
|
)
|
|
if response["action"] not in {"accept", "decline", "cancel"}:
|
|
raise ValueError("MCP elicitation action is invalid")
|
|
return
|
|
if method in {
|
|
"item/commandExecution/requestApproval",
|
|
"item/fileChange/requestApproval",
|
|
"applyPatchApproval",
|
|
"execCommandApproval",
|
|
}:
|
|
_exact_keys(response, required={"decision"}, allowed={"decision"}, label=method)
|
|
if method == "item/commandExecution/requestApproval":
|
|
_command_approval_decision(response["decision"])
|
|
elif method == "item/fileChange/requestApproval":
|
|
if response["decision"] not in {"accept", "acceptForSession", "decline", "cancel"}:
|
|
raise ValueError("file-change approval decision is invalid")
|
|
else:
|
|
_legacy_approval_decision(response["decision"])
|
|
return
|
|
if method == "item/permissions/requestApproval":
|
|
_exact_keys(
|
|
response,
|
|
required={"permissions"},
|
|
allowed={"permissions", "scope", "strictAutoReview"},
|
|
label=method,
|
|
)
|
|
_permission_profile(response["permissions"])
|
|
if response.get("scope", "turn") not in {"turn", "session"}:
|
|
raise ValueError("permission grant scope is invalid")
|
|
if response.get("strictAutoReview") is not None and not isinstance(
|
|
response["strictAutoReview"], bool
|
|
):
|
|
raise ValueError("strictAutoReview must be a boolean or null")
|
|
return
|
|
raise ValueError(f"unsupported pending app-server request method: {method}")
|
|
|
|
|
|
class UnixWebSocket:
|
|
"""Minimal RFC 6455 client for Codex's Unix-domain app-server listener."""
|
|
|
|
def __init__(self, path: Path, *, timeout: float) -> None:
|
|
if not path.is_absolute():
|
|
raise AppServerError("app-server Unix socket path must be absolute")
|
|
if len(os.fsencode(path)) >= 104:
|
|
raise AppServerError("app-server Unix socket path exceeds the portable AF_UNIX limit")
|
|
self.path = path
|
|
self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
self._socket.settimeout(timeout)
|
|
self._buffer = bytearray()
|
|
self._send_lock = threading.Lock()
|
|
self._closed = False
|
|
try:
|
|
self._socket.connect(str(path))
|
|
self._handshake()
|
|
self._socket.settimeout(None)
|
|
except BaseException:
|
|
self._socket.close()
|
|
self._closed = True
|
|
raise
|
|
|
|
@property
|
|
def closed(self) -> bool:
|
|
return self._closed
|
|
|
|
def _handshake(self) -> None:
|
|
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
request = (
|
|
"GET / HTTP/1.1\r\n"
|
|
"Host: localhost\r\n"
|
|
"Upgrade: websocket\r\n"
|
|
"Connection: Upgrade\r\n"
|
|
f"Sec-WebSocket-Key: {key}\r\n"
|
|
"Sec-WebSocket-Version: 13\r\n\r\n"
|
|
).encode("ascii")
|
|
self._socket.sendall(request)
|
|
response = bytearray()
|
|
marker = b"\r\n\r\n"
|
|
while marker not in response:
|
|
chunk = self._socket.recv(4096)
|
|
if not chunk:
|
|
raise AppServerError("app-server closed during WebSocket handshake")
|
|
response.extend(chunk)
|
|
if len(response) > 64 * 1024:
|
|
raise AppServerError("app-server WebSocket handshake exceeds 64 KiB")
|
|
raw_headers, trailing = bytes(response).split(marker, 1)
|
|
lines = raw_headers.decode("iso-8859-1").split("\r\n")
|
|
status = lines[0].split(" ", 2) if lines else []
|
|
if len(status) < 2 or status[0] != "HTTP/1.1" or status[1] != "101":
|
|
raise AppServerError(
|
|
f"app-server rejected WebSocket upgrade: {lines[0] if lines else ''}"
|
|
)
|
|
headers: dict[str, list[str]] = {}
|
|
for line in lines[1:]:
|
|
if ":" not in line:
|
|
raise AppServerError("app-server returned a malformed WebSocket header")
|
|
name, value = line.split(":", 1)
|
|
name = name.strip().lower()
|
|
if not name:
|
|
raise AppServerError("app-server returned an empty WebSocket header name")
|
|
headers.setdefault(name, []).append(value.strip())
|
|
expected = base64.b64encode(
|
|
hashlib.sha1( # noqa: S324 - mandated by RFC 6455, not used for security
|
|
(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")
|
|
).digest()
|
|
).decode("ascii")
|
|
if headers.get("sec-websocket-accept") != [expected]:
|
|
raise AppServerError("app-server returned an invalid WebSocket accept digest")
|
|
upgrade_tokens = {
|
|
token.strip().casefold()
|
|
for value in headers.get("upgrade", [])
|
|
for token in value.split(",")
|
|
if token.strip()
|
|
}
|
|
connection_tokens = {
|
|
token.strip().casefold()
|
|
for value in headers.get("connection", [])
|
|
for token in value.split(",")
|
|
if token.strip()
|
|
}
|
|
if "websocket" not in upgrade_tokens:
|
|
raise AppServerError("app-server WebSocket upgrade header is missing")
|
|
if "upgrade" not in connection_tokens:
|
|
raise AppServerError("app-server WebSocket connection upgrade token is missing")
|
|
if "sec-websocket-extensions" in headers or "sec-websocket-protocol" in headers:
|
|
raise AppServerError("app-server negotiated an unrequested WebSocket feature")
|
|
self._buffer.extend(trailing)
|
|
|
|
def _recv_exact(self, size: int) -> bytes:
|
|
while len(self._buffer) < size:
|
|
chunk = self._socket.recv(max(4096, size - len(self._buffer)))
|
|
if not chunk:
|
|
raise AppServerError("app-server WebSocket transport closed")
|
|
self._buffer.extend(chunk)
|
|
value = bytes(self._buffer[:size])
|
|
del self._buffer[:size]
|
|
return value
|
|
|
|
@staticmethod
|
|
def _frame(opcode: int, payload: bytes) -> bytes:
|
|
if len(payload) > MAX_WEBSOCKET_MESSAGE_BYTES:
|
|
raise AppServerError("app-server WebSocket message exceeds 64 MiB")
|
|
if opcode >= 0x8 and len(payload) > 125:
|
|
raise AppServerError("app-server WebSocket control frame exceeds 125 bytes")
|
|
if opcode == 0x8 and len(payload) == 1:
|
|
raise AppServerError("app-server WebSocket close payload is malformed")
|
|
first = 0x80 | opcode
|
|
length = len(payload)
|
|
if length < 126:
|
|
header = bytes((first, 0x80 | length))
|
|
elif length <= 0xFFFF:
|
|
header = bytes((first, 0x80 | 126)) + struct.pack("!H", length)
|
|
else:
|
|
header = bytes((first, 0x80 | 127)) + struct.pack("!Q", length)
|
|
mask = os.urandom(4)
|
|
masked = bytes(value ^ mask[index & 3] for index, value in enumerate(payload))
|
|
return header + mask + masked
|
|
|
|
def send_text(self, value: str) -> None:
|
|
encoded = value.encode("utf-8")
|
|
with self._send_lock:
|
|
if self._closed:
|
|
raise AppServerError("app-server WebSocket transport is closed")
|
|
try:
|
|
self._socket.sendall(self._frame(0x1, encoded))
|
|
except OSError as exc:
|
|
raise AppServerError(f"app-server WebSocket write failed: {exc}") from exc
|
|
|
|
def _send_control(self, opcode: int, payload: bytes = b"") -> None:
|
|
with self._send_lock:
|
|
if self._closed:
|
|
return
|
|
self._socket.sendall(self._frame(opcode, payload))
|
|
|
|
def _close_socket(self) -> None:
|
|
if self._closed:
|
|
return
|
|
self._closed = True
|
|
with contextlib.suppress(OSError):
|
|
self._socket.shutdown(socket.SHUT_RDWR)
|
|
with contextlib.suppress(OSError):
|
|
self._socket.close()
|
|
|
|
def _protocol_failure(self, message: str, *, close_code: int = 1002) -> None:
|
|
with contextlib.suppress(OSError, AppServerError):
|
|
self._send_control(0x8, struct.pack("!H", close_code))
|
|
self._close_socket()
|
|
raise AppServerError(message)
|
|
|
|
@staticmethod
|
|
def _valid_close_code(code: int) -> bool:
|
|
return (
|
|
code
|
|
in {
|
|
1000,
|
|
1001,
|
|
1002,
|
|
1003,
|
|
1007,
|
|
1008,
|
|
1009,
|
|
1010,
|
|
1011,
|
|
1012,
|
|
1013,
|
|
1014,
|
|
}
|
|
or 3000 <= code <= 4999
|
|
)
|
|
|
|
def receive_text(self) -> str:
|
|
fragments = bytearray()
|
|
fragmented = False
|
|
while True:
|
|
first, second = self._recv_exact(2)
|
|
final = bool(first & 0x80)
|
|
if first & 0x70:
|
|
self._protocol_failure("app-server WebSocket frame has unsupported RSV bits")
|
|
opcode = first & 0x0F
|
|
masked = bool(second & 0x80)
|
|
if masked:
|
|
self._protocol_failure("app-server sent a masked WebSocket server frame")
|
|
length = second & 0x7F
|
|
control = opcode >= 0x8
|
|
if control and (not final or length > 125):
|
|
self._protocol_failure("app-server sent a malformed WebSocket control frame")
|
|
if length == 126:
|
|
length = struct.unpack("!H", self._recv_exact(2))[0]
|
|
if length < 126:
|
|
self._protocol_failure("app-server used a non-minimal WebSocket frame length")
|
|
elif length == 127:
|
|
encoded_length = self._recv_exact(8)
|
|
if encoded_length[0] & 0x80:
|
|
self._protocol_failure("app-server WebSocket frame length has its high bit set")
|
|
length = struct.unpack("!Q", encoded_length)[0]
|
|
if length <= 0xFFFF:
|
|
self._protocol_failure("app-server used a non-minimal WebSocket frame length")
|
|
if length > MAX_WEBSOCKET_MESSAGE_BYTES:
|
|
self._protocol_failure(
|
|
"app-server WebSocket message exceeds 64 MiB", close_code=1009
|
|
)
|
|
payload = self._recv_exact(length)
|
|
if opcode == 0x8:
|
|
if len(payload) == 1:
|
|
self._protocol_failure("app-server sent a malformed WebSocket close payload")
|
|
if payload:
|
|
close_code = struct.unpack("!H", payload[:2])[0]
|
|
if not self._valid_close_code(close_code):
|
|
self._protocol_failure("app-server sent an invalid WebSocket close code")
|
|
try:
|
|
payload[2:].decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
self._protocol_failure(
|
|
"app-server sent a non-UTF-8 WebSocket close reason",
|
|
close_code=1007,
|
|
)
|
|
with contextlib.suppress(OSError, AppServerError):
|
|
self._send_control(0x8, payload)
|
|
self._close_socket()
|
|
raise AppServerError("app-server WebSocket transport closed")
|
|
if opcode == 0x9:
|
|
self._send_control(0xA, payload)
|
|
continue
|
|
if opcode == 0xA:
|
|
continue
|
|
if opcode == 0x1:
|
|
if fragmented:
|
|
self._protocol_failure("app-server started a nested WebSocket message")
|
|
fragments.extend(payload)
|
|
fragmented = not final
|
|
elif opcode == 0x0 and fragmented:
|
|
fragments.extend(payload)
|
|
fragmented = not final
|
|
elif opcode == 0x2:
|
|
self._protocol_failure(
|
|
"app-server emitted unsupported WebSocket binary data", close_code=1003
|
|
)
|
|
else:
|
|
self._protocol_failure(f"unsupported app-server WebSocket opcode: {opcode}")
|
|
if len(fragments) > MAX_WEBSOCKET_MESSAGE_BYTES:
|
|
self._protocol_failure(
|
|
"app-server WebSocket message exceeds 64 MiB", close_code=1009
|
|
)
|
|
if not fragmented:
|
|
try:
|
|
return fragments.decode("utf-8")
|
|
except UnicodeDecodeError as exc:
|
|
with contextlib.suppress(OSError, AppServerError):
|
|
self._send_control(0x8, struct.pack("!H", 1007))
|
|
self._close_socket()
|
|
raise AppServerError("app-server emitted non-UTF-8 WebSocket text") from exc
|
|
|
|
def close(self) -> None:
|
|
if self._closed:
|
|
return
|
|
with contextlib.suppress(OSError, AppServerError):
|
|
self._send_control(0x8, struct.pack("!H", 1000))
|
|
self._close_socket()
|
|
|
|
|
|
class AppServerClient:
|
|
"""One protocol client connected to an isolated Unix app-server host."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
socket_path: Path,
|
|
cwd: Path,
|
|
env: Mapping[str, str],
|
|
events_path: Path,
|
|
stderr_path: Path,
|
|
approval_policy: str,
|
|
command: Sequence[str] | None = None,
|
|
on_message: Callable[[dict[str, Any]], None] | None = None,
|
|
) -> None:
|
|
self.command = list(command) if command is not None else None
|
|
self.socket_path = socket_path.expanduser().resolve()
|
|
self.cwd = cwd
|
|
self.env = dict(env)
|
|
self.events_path = events_path
|
|
self.stderr_path = stderr_path
|
|
self.approval_policy = approval_policy
|
|
self.on_message = on_message
|
|
self.process: subprocess.Popen[bytes] | None = None
|
|
self.transport: UnixWebSocket | None = None
|
|
self.initialize_result: dict[str, Any] | None = None
|
|
self._stderr: Any = None
|
|
self._reader: threading.Thread | None = None
|
|
self._write_lock = threading.Lock()
|
|
self._condition = threading.Condition()
|
|
self._responses: dict[int, dict[str, Any]] = {}
|
|
self._server_requests: dict[str, dict[str, Any]] = {}
|
|
self._dynamic_mcp_tools: dict[str, tuple[str, str]] = {}
|
|
self._dynamic_call_threads: set[threading.Thread] = set()
|
|
self._dynamic_call_lock = threading.Lock()
|
|
self._next_id = 1
|
|
self._closed = False
|
|
self._reader_error: str | None = None
|
|
|
|
@property
|
|
def pid(self) -> int | None:
|
|
return self.process.pid if self.process is not None else None
|
|
|
|
@property
|
|
def alive(self) -> bool:
|
|
process_alive = self.process is None or self.process.poll() is None
|
|
return bool(
|
|
process_alive
|
|
and self.transport is not None
|
|
and not self.transport.closed
|
|
and not self._closed
|
|
)
|
|
|
|
@property
|
|
def pending_server_requests(self) -> list[dict[str, Any]]:
|
|
with self._condition:
|
|
return [dict(value) for value in self._server_requests.values()]
|
|
|
|
def pending_server_requests_for_thread(self, thread_id: str) -> list[dict[str, Any]]:
|
|
"""Return only pending requests owned by one thread on a shared host."""
|
|
|
|
return [
|
|
request
|
|
for request in self.pending_server_requests
|
|
if app_server_message_thread_id(request) == thread_id
|
|
]
|
|
|
|
def install_switchyard_mcp_bridge(
|
|
self,
|
|
*,
|
|
timeout: float = APP_SERVER_LIFECYCLE_TIMEOUT_SECONDS,
|
|
) -> list[dict[str, Any]]:
|
|
"""Expose configured MCP tools as flat app-server dynamic functions.
|
|
|
|
Codex 0.149 emits MCP tools as Responses namespace entries. Switchyard
|
|
0.2.0 accepts ordinary function tools but drops namespace entries while
|
|
translating requests for OpenAI-compatible third-party providers. The
|
|
app-server dynamic-tool protocol gives those providers an equivalent
|
|
flat function surface without changing Codex or bypassing its MCP
|
|
connection, allowlist, approval, and result handling.
|
|
"""
|
|
|
|
cursor: str | None = None
|
|
seen_cursors: set[str] = set()
|
|
rows: list[Mapping[str, Any]] = []
|
|
while True:
|
|
params: dict[str, Any] = {
|
|
"detail": "toolsAndAuthOnly",
|
|
"limit": 100,
|
|
}
|
|
if cursor is not None:
|
|
params["cursor"] = cursor
|
|
response = self.request("mcpServerStatus/list", params, timeout=timeout)
|
|
data = response.get("data") if isinstance(response, Mapping) else None
|
|
if not isinstance(data, list) or not all(isinstance(row, Mapping) for row in data):
|
|
raise AppServerError("mcpServerStatus/list returned invalid server data")
|
|
rows.extend(data)
|
|
next_cursor = response.get("nextCursor")
|
|
if next_cursor is None:
|
|
break
|
|
if not isinstance(next_cursor, str) or next_cursor in seen_cursors:
|
|
raise AppServerError("mcpServerStatus/list returned an invalid cursor sequence")
|
|
seen_cursors.add(next_cursor)
|
|
cursor = next_cursor
|
|
|
|
routes: dict[str, tuple[str, str]] = {}
|
|
specs: list[dict[str, Any]] = []
|
|
for row in rows:
|
|
server = row.get("name")
|
|
tools = row.get("tools")
|
|
if not isinstance(server, str) or not isinstance(tools, Mapping):
|
|
raise AppServerError("mcpServerStatus/list returned an invalid server row")
|
|
for listed_name, raw_tool in sorted(tools.items(), key=lambda item: str(item[0])):
|
|
if not isinstance(listed_name, str) or not isinstance(raw_tool, Mapping):
|
|
raise AppServerError("mcpServerStatus/list returned an invalid tool row")
|
|
tool = raw_tool.get("name", listed_name)
|
|
if not isinstance(tool, str) or not tool:
|
|
raise AppServerError("mcpServerStatus/list returned an invalid tool name")
|
|
name = _flat_mcp_dynamic_tool_name(server, tool)
|
|
if name in routes and routes[name] != (server, tool):
|
|
name = _flat_mcp_dynamic_tool_name(server, tool, salt=listed_name)
|
|
if name in routes:
|
|
raise AppServerError(f"flat MCP dynamic tool name collision: {name}")
|
|
input_schema = raw_tool.get("inputSchema", {"type": "object"})
|
|
if not isinstance(input_schema, Mapping):
|
|
raise AppServerError(f"MCP tool {server}.{tool} has an invalid input schema")
|
|
description = raw_tool.get("description")
|
|
specs.append(
|
|
{
|
|
"type": "function",
|
|
"name": name,
|
|
"description": (
|
|
str(description)
|
|
if isinstance(description, str)
|
|
else f"Call {tool} on the {server} MCP server."
|
|
),
|
|
"inputSchema": dict(input_schema),
|
|
}
|
|
)
|
|
routes[name] = (server, tool)
|
|
self._dynamic_mcp_tools = routes
|
|
return specs
|
|
|
|
def _run_dynamic_mcp_tool_call(
|
|
self,
|
|
request_id: int | str,
|
|
message: Mapping[str, Any],
|
|
) -> None:
|
|
"""Route one flat dynamic function through Codex's own MCP API."""
|
|
|
|
try:
|
|
params = message.get("params")
|
|
if not isinstance(params, Mapping):
|
|
raise AppServerError("dynamic tool request params are invalid")
|
|
thread_id = params.get("threadId")
|
|
namespace = params.get("namespace")
|
|
flat_name = params.get("tool")
|
|
if not isinstance(thread_id, str) or not thread_id:
|
|
raise AppServerError("dynamic tool request has no thread identity")
|
|
if namespace is not None:
|
|
raise AppServerError("flat MCP bridge received a namespaced dynamic tool call")
|
|
if not isinstance(flat_name, str) or flat_name not in self._dynamic_mcp_tools:
|
|
raise AppServerError(f"unknown flat MCP dynamic tool: {flat_name!r}")
|
|
server, tool = self._dynamic_mcp_tools[flat_name]
|
|
call_params: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"server": server,
|
|
"tool": tool,
|
|
}
|
|
if "arguments" in params:
|
|
call_params["arguments"] = params["arguments"]
|
|
result = self.request(
|
|
"mcpServer/tool/call",
|
|
call_params,
|
|
timeout=APP_SERVER_DYNAMIC_TOOL_TIMEOUT_SECONDS,
|
|
)
|
|
if not isinstance(result, Mapping):
|
|
raise AppServerError("mcpServer/tool/call returned a non-object result")
|
|
self.respond(
|
|
request_id,
|
|
{
|
|
"contentItems": _dynamic_tool_content_items(result),
|
|
"success": result.get("isError") is not True,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
with contextlib.suppress(Exception):
|
|
self.respond(
|
|
request_id,
|
|
{
|
|
"contentItems": [
|
|
{
|
|
"type": "inputText",
|
|
"text": f"MCP bridge error: {type(exc).__name__}: {exc}",
|
|
}
|
|
],
|
|
"success": False,
|
|
},
|
|
)
|
|
finally:
|
|
with self._dynamic_call_lock:
|
|
self._dynamic_call_threads.discard(threading.current_thread())
|
|
|
|
def _dispatch_dynamic_mcp_tool_call(
|
|
self,
|
|
request_id: int | str,
|
|
message: Mapping[str, Any],
|
|
) -> None:
|
|
worker = threading.Thread(
|
|
target=self._run_dynamic_mcp_tool_call,
|
|
args=(request_id, dict(message)),
|
|
name=f"codex-app-server-dynamic-tool-{request_id}",
|
|
daemon=True,
|
|
)
|
|
with self._dynamic_call_lock:
|
|
self._dynamic_call_threads.add(worker)
|
|
worker.start()
|
|
|
|
@staticmethod
|
|
def _server_request_key(request_id: int | str) -> str:
|
|
prefix = "integer" if isinstance(request_id, int) else "string"
|
|
return f"{prefix}:{request_id}"
|
|
|
|
def start(
|
|
self,
|
|
*,
|
|
timeout: float = 30.0,
|
|
on_started: Callable[[int], None] | None = None,
|
|
) -> dict[str, Any]:
|
|
if self.transport is not None or self.process is not None:
|
|
raise AppServerError("app-server client has already been started")
|
|
self.stderr_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
self.socket_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
if self.command is not None:
|
|
if self.socket_path.exists() or self.socket_path.is_symlink():
|
|
raise AppServerError(
|
|
f"refusing to replace an existing app-server socket: {self.socket_path}"
|
|
)
|
|
self._stderr = self.stderr_path.open("ab", buffering=0)
|
|
try:
|
|
self.process = subprocess.Popen(
|
|
self.command,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=self._stderr,
|
|
stderr=self._stderr,
|
|
cwd=self.cwd,
|
|
env=self.env,
|
|
start_new_session=True,
|
|
close_fds=True,
|
|
)
|
|
except BaseException:
|
|
self._stderr.close()
|
|
self._stderr = None
|
|
raise
|
|
try:
|
|
# Publish the isolated process-group identity before performing any
|
|
# potentially slow protocol or MCP initialization. This closes the
|
|
# bootstrap interval in which an operator stop could otherwise
|
|
# lose ownership of the newly spawned app-server descendants.
|
|
if on_started is not None and self.process is not None:
|
|
on_started(self.process.pid)
|
|
deadline = time.monotonic() + timeout
|
|
last_error = "socket not ready"
|
|
while self.transport is None:
|
|
if self.process is not None and self.process.poll() is not None:
|
|
raise AppServerError(
|
|
f"app-server exited before accepting connections: {self.process.returncode}"
|
|
)
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise AppServerError(
|
|
f"app-server Unix socket did not become ready: {last_error}"
|
|
)
|
|
try:
|
|
self.transport = UnixWebSocket(
|
|
self.socket_path,
|
|
timeout=min(1.0, remaining),
|
|
)
|
|
except (AppServerError, OSError) as exc:
|
|
last_error = f"{type(exc).__name__}: {exc}"
|
|
time.sleep(min(0.05, remaining))
|
|
self._reader = threading.Thread(
|
|
target=self._read_loop,
|
|
name=f"codex-app-server-{self.pid or self.socket_path.name}",
|
|
daemon=True,
|
|
)
|
|
self._reader.start()
|
|
result = self.request(
|
|
"initialize",
|
|
{
|
|
"clientInfo": {
|
|
"name": "codex-mmo",
|
|
"title": "Codex Multi-Model Orchestrator",
|
|
"version": package_version(),
|
|
},
|
|
"capabilities": {"experimentalApi": True},
|
|
},
|
|
timeout=timeout,
|
|
)
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
if not isinstance(result, dict):
|
|
self.close()
|
|
raise AppServerError("app-server initialize returned a non-object result")
|
|
self.initialize_result = result
|
|
self.notify("initialized", {})
|
|
return result
|
|
|
|
def _record(self, direction: str, message: Mapping[str, Any]) -> None:
|
|
append_jsonl(
|
|
self.events_path,
|
|
{
|
|
"recorded_at": utc_now(),
|
|
"direction": direction,
|
|
"message": dict(message),
|
|
},
|
|
)
|
|
|
|
def _read_loop(self) -> None:
|
|
transport = self.transport
|
|
if transport is None:
|
|
raise AppServerError("app-server WebSocket transport was not initialized")
|
|
try:
|
|
while True:
|
|
line = transport.receive_text()
|
|
try:
|
|
value = strict_json_loads(line)
|
|
except (json.JSONDecodeError, ValueError) as exc:
|
|
raise AppServerError(f"invalid app-server JSON: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise AppServerError("app-server emitted a non-object protocol message")
|
|
if "jsonrpc" in value:
|
|
raise AppServerError("app-server emitted an unexpected JSON-RPC version field")
|
|
self._record("received", value)
|
|
message_id = value.get("id")
|
|
if message_id is not None and ("result" in value or "error" in value):
|
|
if not isinstance(message_id, int) or isinstance(message_id, bool):
|
|
raise AppServerError("app-server response id is not an integer")
|
|
if ("result" in value) == ("error" in value):
|
|
raise AppServerError(
|
|
"app-server response must contain exactly one of result or error"
|
|
)
|
|
if "error" in value:
|
|
error = value["error"]
|
|
if not isinstance(error, Mapping):
|
|
raise AppServerError("app-server response error is not an object")
|
|
code = error.get("code")
|
|
if not isinstance(code, int) or isinstance(code, bool):
|
|
raise AppServerError("app-server response error code is not an integer")
|
|
if not isinstance(error.get("message"), str):
|
|
raise AppServerError(
|
|
"app-server response error message is not a string"
|
|
)
|
|
with self._condition:
|
|
self._responses[message_id] = value
|
|
self._condition.notify_all()
|
|
elif message_id is not None and isinstance(value.get("method"), str):
|
|
if not (
|
|
isinstance(message_id, str)
|
|
or (isinstance(message_id, int) and not isinstance(message_id, bool))
|
|
):
|
|
raise AppServerError("app-server request id must be an integer or string")
|
|
key = self._server_request_key(message_id)
|
|
method = str(value["method"])
|
|
if method == "currentTime/read":
|
|
self.respond(message_id, {"currentTimeAt": int(time.time())})
|
|
elif method == "item/tool/call" and self._dynamic_mcp_tools:
|
|
# A bridge call must run off the reader thread: its
|
|
# mcpServer/tool/call request is answered by this same
|
|
# reader, so handling it inline would deadlock.
|
|
self._dispatch_dynamic_mcp_tool_call(message_id, value)
|
|
if self.on_message is not None:
|
|
with contextlib.suppress(Exception):
|
|
self.on_message(value)
|
|
elif method in APPROVAL_REQUEST_METHODS and self.approval_policy == "never":
|
|
self._deny_approval(message_id, method)
|
|
elif method in PENDING_SERVER_REQUEST_METHODS:
|
|
with self._condition:
|
|
self._server_requests[key] = value
|
|
self._condition.notify_all()
|
|
if self.on_message is not None:
|
|
with contextlib.suppress(Exception):
|
|
self.on_message(value)
|
|
elif method in UNSUPPORTED_SERVER_REQUEST_METHODS:
|
|
self.respond_error(
|
|
message_id,
|
|
-32601,
|
|
f"{method} is deliberately not provided by Codex MMO",
|
|
)
|
|
else:
|
|
self.respond_error(
|
|
message_id,
|
|
-32601,
|
|
f"unsupported app-server request method: {method}",
|
|
)
|
|
elif value.get("method") == "serverRequest/resolved":
|
|
params = value.get("params")
|
|
request_id = params.get("requestId") if isinstance(params, Mapping) else None
|
|
if isinstance(request_id, str) or (
|
|
isinstance(request_id, int) and not isinstance(request_id, bool)
|
|
):
|
|
with self._condition:
|
|
self._server_requests.pop(self._server_request_key(request_id), None)
|
|
self._condition.notify_all()
|
|
if self.on_message is not None:
|
|
with contextlib.suppress(Exception):
|
|
self.on_message(value)
|
|
elif self.on_message is not None:
|
|
with contextlib.suppress(Exception):
|
|
self.on_message(value)
|
|
except Exception as exc:
|
|
self._reader_error = f"{type(exc).__name__}: {exc}"
|
|
finally:
|
|
with self._condition:
|
|
self._closed = True
|
|
self._condition.notify_all()
|
|
|
|
def _deny_approval(self, request_id: Any, method: str) -> None:
|
|
if method in {
|
|
"item/commandExecution/requestApproval",
|
|
"item/fileChange/requestApproval",
|
|
}:
|
|
result: dict[str, Any] = {"decision": "decline"}
|
|
elif method == "item/permissions/requestApproval":
|
|
self.respond_error(request_id, -32001, "permission escalation denied by MMO policy")
|
|
return
|
|
else:
|
|
result = {
|
|
"decision": {
|
|
"denied": {
|
|
"rejection": "denied by Codex MMO approval_policy=never",
|
|
}
|
|
}
|
|
}
|
|
self.respond(request_id, result)
|
|
|
|
def _send(self, message: Mapping[str, Any]) -> None:
|
|
transport = self.transport
|
|
if transport is None or transport.closed or self._closed:
|
|
raise AppServerError("app-server is not running")
|
|
encoded = json.dumps(
|
|
dict(message), ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
|
)
|
|
with self._write_lock:
|
|
# Keep the durable trace in the exact order bytes are serialized
|
|
# onto the app-server transport. Recording before this lock
|
|
# allowed concurrent control/approval writers to publish an order
|
|
# different from the wire order.
|
|
self._record("sent", message)
|
|
try:
|
|
transport.send_text(encoded)
|
|
except (AppServerError, OSError) as exc:
|
|
raise AppServerError(f"app-server write failed: {exc}") from exc
|
|
|
|
def request(self, method: str, params: Mapping[str, Any], *, timeout: float = 30.0) -> Any:
|
|
deadline = time.monotonic() + timeout
|
|
for attempt in range(len(APP_SERVER_OVERLOAD_RETRY_DELAYS_SECONDS) + 1):
|
|
with self._condition:
|
|
request_id = self._next_id
|
|
self._next_id += 1
|
|
self._send({"id": request_id, "method": method, "params": dict(params)})
|
|
with self._condition:
|
|
while request_id not in self._responses:
|
|
if self._closed:
|
|
detail = self._reader_error or "app-server transport closed"
|
|
raise AppServerError(detail)
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
self._responses.pop(request_id, None)
|
|
raise AppServerError(f"app-server request timed out: {method}")
|
|
self._condition.wait(timeout=remaining)
|
|
response = self._responses.pop(request_id)
|
|
if "error" not in response:
|
|
return response.get("result")
|
|
error = response["error"]
|
|
code = int(error["code"])
|
|
message = str(error["message"])
|
|
overload = code == -32001 and message == "Server overloaded; retry later."
|
|
if overload and attempt < len(APP_SERVER_OVERLOAD_RETRY_DELAYS_SECONDS):
|
|
base_delay = APP_SERVER_OVERLOAD_RETRY_DELAYS_SECONDS[attempt]
|
|
delay = base_delay * random.uniform(0.5, 1.0)
|
|
if delay < deadline - time.monotonic():
|
|
time.sleep(delay)
|
|
continue
|
|
raise AppServerError(f"app-server {method} failed ({code}): {message}")
|
|
raise AssertionError("app-server retry loop exhausted without returning")
|
|
|
|
def notify(self, method: str, params: Mapping[str, Any]) -> None:
|
|
self._send({"method": method, "params": dict(params)})
|
|
|
|
def respond(self, request_id: Any, result: Mapping[str, Any]) -> None:
|
|
self._send({"id": request_id, "result": dict(result)})
|
|
if isinstance(request_id, str) or (
|
|
isinstance(request_id, int) and not isinstance(request_id, bool)
|
|
):
|
|
with self._condition:
|
|
self._server_requests.pop(self._server_request_key(request_id), None)
|
|
|
|
def respond_error(self, request_id: Any, code: int, message: str) -> None:
|
|
self._send(
|
|
{
|
|
"id": request_id,
|
|
"error": {"code": int(code), "message": message},
|
|
}
|
|
)
|
|
if isinstance(request_id, str) or (
|
|
isinstance(request_id, int) and not isinstance(request_id, bool)
|
|
):
|
|
with self._condition:
|
|
self._server_requests.pop(self._server_request_key(request_id), None)
|
|
|
|
def close(self) -> None:
|
|
"""Detach this client without changing the app-server host or its goal."""
|
|
|
|
if self.transport is not None:
|
|
self.transport.close()
|
|
if self._reader is not None:
|
|
self._reader.join(timeout=2.0)
|
|
if self._stderr is not None:
|
|
with contextlib.suppress(Exception):
|
|
self._stderr.close()
|
|
with self._condition:
|
|
self._closed = True
|
|
self._condition.notify_all()
|
|
|
|
def stop_host(self, *, grace_seconds: float = 8.0) -> None:
|
|
"""Detach and retire the isolated app-server process this client started."""
|
|
|
|
process = self.process
|
|
self.close()
|
|
if process is not None:
|
|
terminate_process_group(process.pid, grace_seconds=grace_seconds)
|
|
with contextlib.suppress(Exception):
|
|
process.wait(timeout=2.0)
|
|
if self.socket_path.is_socket():
|
|
with contextlib.suppress(OSError):
|
|
self.socket_path.unlink()
|
|
|
|
|
|
def last_agent_message(turn: Any) -> str:
|
|
if not isinstance(turn, Mapping):
|
|
return ""
|
|
messages = [
|
|
str(item.get("text", ""))
|
|
for item in turn.get("items", [])
|
|
if isinstance(item, Mapping) and item.get("type") == "agentMessage"
|
|
]
|
|
return next((value for value in reversed(messages) if value.strip()), "")
|
|
|
|
|
|
def bounded_goal_objective(value: str) -> str:
|
|
"""Return one deterministic app-server goal objective within its hard limit.
|
|
|
|
The complete delegated or root prompt remains the first turn input. Long
|
|
objectives retain a recognizable prefix plus a digest that lets operators
|
|
correlate the bounded goal with those full instructions.
|
|
"""
|
|
|
|
objective = value.strip()
|
|
if not objective:
|
|
raise ValueError("goal objective must be a non-empty string")
|
|
if len(objective) <= APP_SERVER_GOAL_OBJECTIVE_MAX_CHARS:
|
|
return objective
|
|
digest = hashlib.sha256(objective.encode("utf-8")).hexdigest()
|
|
suffix = f"\n\n[Full instructions remain in the initial turn; sha256={digest}]"
|
|
prefix_chars = APP_SERVER_GOAL_OBJECTIVE_MAX_CHARS - len(suffix)
|
|
return objective[:prefix_chars].rstrip() + suffix
|
|
|
|
|
|
def completed_turn_presentable_text(events_path: Path, turn_id: str) -> str:
|
|
"""Recover the last completed user-visible item for one exact root turn."""
|
|
|
|
if not events_path.is_file() or events_path.is_symlink():
|
|
return ""
|
|
result = ""
|
|
with events_path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
for raw in handle:
|
|
try:
|
|
record = strict_json_loads(raw)
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
if not isinstance(record, Mapping) or record.get("direction") != "received":
|
|
continue
|
|
message = record.get("message")
|
|
if not isinstance(message, Mapping) or message.get("method") != "item/completed":
|
|
continue
|
|
params = message.get("params")
|
|
if not isinstance(params, Mapping) or params.get("turnId") != turn_id:
|
|
continue
|
|
item = params.get("item")
|
|
if not isinstance(item, Mapping) or item.get("type") not in {
|
|
"agentMessage",
|
|
"plan",
|
|
}:
|
|
continue
|
|
text = item.get("text")
|
|
if isinstance(text, str) and text.strip():
|
|
result = text
|
|
return result
|
|
|
|
|
|
def turn_input(prompt: str, attachments: Sequence[str]) -> list[dict[str, Any]]:
|
|
items: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
|
|
items.extend(
|
|
{"type": "localImage", "path": path}
|
|
for path in attachments
|
|
if Path(path).suffix.lower() in {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
|
)
|
|
return items
|
|
|
|
|
|
def resumed_turns(
|
|
thread: Mapping[str, Any],
|
|
prior_turn_id: Any,
|
|
*,
|
|
turn_start_pending: bool = False,
|
|
) -> tuple[dict[str, Any] | None, str | None]:
|
|
"""Return an exact recovered terminal turn and any remaining active turn."""
|
|
|
|
turns = [turn for turn in thread.get("turns", []) if isinstance(turn, dict)]
|
|
prior_index = next(
|
|
(
|
|
index
|
|
for index in range(len(turns) - 1, -1, -1)
|
|
if isinstance(prior_turn_id, str) and turns[index].get("id") == prior_turn_id
|
|
),
|
|
None,
|
|
)
|
|
if turn_start_pending:
|
|
appended = turns[(prior_index + 1) if isinstance(prior_index, int) else 0 :]
|
|
candidate = appended[-1] if appended else None
|
|
else:
|
|
candidate = turns[prior_index] if isinstance(prior_index, int) else None
|
|
terminal = (
|
|
candidate
|
|
if isinstance(candidate, dict) and candidate.get("status") != "inProgress"
|
|
else None
|
|
)
|
|
active = next(
|
|
(turn for turn in reversed(turns) if turn.get("status") == "inProgress"),
|
|
None,
|
|
)
|
|
active_id = active.get("id") if isinstance(active, dict) else None
|
|
return terminal, str(active_id) if isinstance(active_id, str) else None
|
|
|
|
|
|
class PersistentThreadHost:
|
|
"""Shared durable thread/turn state for root and delegated app-server hosts."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
state: dict[str, Any] | None = None,
|
|
state_lock: threading.RLock | None = None,
|
|
on_state_change: Callable[[dict[str, Any], Mapping[str, Any]], None] | None = None,
|
|
) -> None:
|
|
self.state = state if state is not None else {}
|
|
self.state_lock = state_lock or threading.RLock()
|
|
self.on_state_change = on_state_change
|
|
self.state.setdefault("client", None)
|
|
self.state.setdefault("thread_id", None)
|
|
self.state.setdefault("active_turn_id", None)
|
|
self.state.setdefault("last_turn_id", None)
|
|
self.state.setdefault("turn_start_pending", False)
|
|
self.state.setdefault("completed_turn", None)
|
|
self.state.setdefault("turn_failure", None)
|
|
self.state.setdefault("thread_status", None)
|
|
self.state.setdefault("goal", None)
|
|
self.state.setdefault("token_usage", None)
|
|
self.state.setdefault("last_item", None)
|
|
self.state.setdefault("turn_event", threading.Event())
|
|
|
|
@property
|
|
def client(self) -> AppServerClient:
|
|
with self.state_lock:
|
|
client = self.state.get("client")
|
|
if not isinstance(client, AppServerClient):
|
|
raise AppServerError("app-server host has no connected client")
|
|
return client
|
|
|
|
@property
|
|
def thread_id(self) -> str | None:
|
|
with self.state_lock:
|
|
value = self.state.get("thread_id")
|
|
return str(value) if isinstance(value, str) else None
|
|
|
|
@property
|
|
def active_turn_id(self) -> str | None:
|
|
with self.state_lock:
|
|
value = self.state.get("active_turn_id")
|
|
return str(value) if isinstance(value, str) else None
|
|
|
|
@property
|
|
def last_turn_id(self) -> str | None:
|
|
with self.state_lock:
|
|
value = self.state.get("last_turn_id")
|
|
return str(value) if isinstance(value, str) else None
|
|
|
|
@property
|
|
def completed_turn(self) -> dict[str, Any] | None:
|
|
with self.state_lock:
|
|
value = self.state.get("completed_turn")
|
|
return dict(value) if isinstance(value, Mapping) else None
|
|
|
|
@property
|
|
def turn_event(self) -> threading.Event:
|
|
value = self.state["turn_event"]
|
|
if not isinstance(value, threading.Event):
|
|
raise AppServerError("app-server host turn event is invalid")
|
|
return value
|
|
|
|
def attach_client(self, client: AppServerClient) -> None:
|
|
with self.state_lock:
|
|
self.state["client"] = client
|
|
|
|
def detach_client(self, client: AppServerClient | None = None) -> None:
|
|
with self.state_lock:
|
|
if client is None or self.state.get("client") is client:
|
|
self.state["client"] = None
|
|
|
|
def on_message(self, message: dict[str, Any]) -> None:
|
|
method = message.get("method")
|
|
raw_params = message.get("params")
|
|
params: Mapping[str, Any] = raw_params if isinstance(raw_params, Mapping) else {}
|
|
changes: dict[str, Any] = {}
|
|
with self.state_lock:
|
|
expected_thread_id = self.state.get("thread_id")
|
|
message_thread_id = app_server_message_thread_id(message)
|
|
if method == "thread/started":
|
|
thread = params.get("thread")
|
|
if isinstance(thread, Mapping) and isinstance(thread.get("id"), str):
|
|
changes["thread_started"] = dict(thread)
|
|
elif (
|
|
isinstance(expected_thread_id, str)
|
|
and isinstance(message_thread_id, str)
|
|
and message_thread_id != expected_thread_id
|
|
):
|
|
return
|
|
elif method == "turn/started":
|
|
turn = params.get("turn")
|
|
if isinstance(turn, Mapping) and isinstance(turn.get("id"), str):
|
|
self.state["active_turn_id"] = turn["id"]
|
|
self.state["last_turn_id"] = turn["id"]
|
|
self.state["turn_start_pending"] = False
|
|
self.state["turn_failure"] = None
|
|
changes.update(
|
|
active_turn_id=turn["id"],
|
|
last_turn_id=turn["id"],
|
|
turn_start_pending=False,
|
|
turn_failure=None,
|
|
)
|
|
elif method == "turn/completed":
|
|
turn = params.get("turn")
|
|
if isinstance(turn, Mapping):
|
|
current_turn_id = self.state.get("active_turn_id")
|
|
completed_turn_id = turn.get("id")
|
|
if not isinstance(current_turn_id, str) or completed_turn_id == current_turn_id:
|
|
self.state["completed_turn"] = dict(turn)
|
|
self.state["active_turn_id"] = None
|
|
if isinstance(completed_turn_id, str):
|
|
self.state["last_turn_id"] = completed_turn_id
|
|
self.state["turn_start_pending"] = False
|
|
failure = normalize_turn_failure(turn)
|
|
self.state["turn_failure"] = failure
|
|
changes.update(
|
|
active_turn_id=None,
|
|
last_turn_id=(
|
|
completed_turn_id
|
|
if isinstance(completed_turn_id, str)
|
|
else self.state.get("last_turn_id")
|
|
),
|
|
turn_start_pending=False,
|
|
last_turn_status=turn.get("status"),
|
|
turn_failure=failure,
|
|
)
|
|
self.turn_event.set()
|
|
elif method == "thread/status/changed":
|
|
status = params.get("status")
|
|
self.state["thread_status"] = status
|
|
changes["thread_status"] = status
|
|
elif method == "thread/tokenUsage/updated":
|
|
usage = params.get("tokenUsage")
|
|
self.state["token_usage"] = usage
|
|
changes["token_usage"] = usage
|
|
elif method == "thread/goal/updated":
|
|
goal = params.get("goal")
|
|
if isinstance(goal, Mapping):
|
|
self.state["goal"] = dict(goal)
|
|
changes["goal"] = dict(goal)
|
|
elif method == "thread/goal/cleared":
|
|
self.state["goal"] = None
|
|
changes["goal"] = None
|
|
elif method in {"item/started", "item/completed"}:
|
|
item = params.get("item")
|
|
if isinstance(item, Mapping):
|
|
self.state["last_item"] = dict(item)
|
|
changes["last_item"] = dict(item)
|
|
changes["last_item_event"] = method
|
|
elif method in {
|
|
"turn/diff/updated",
|
|
"turn/plan/updated",
|
|
"mcpServer/startupStatus/updated",
|
|
"model/rerouted",
|
|
"model/verification",
|
|
"warning",
|
|
"error",
|
|
"account/rateLimits/updated",
|
|
}:
|
|
changes["last_observability_event"] = method
|
|
changes["last_observability_params"] = dict(params)
|
|
elif (
|
|
message.get("id") is not None
|
|
and method
|
|
in {
|
|
"item/tool/requestUserInput",
|
|
"mcpServer/elicitation/request",
|
|
}
|
|
| APPROVAL_REQUEST_METHODS
|
|
):
|
|
client = self.state.get("client")
|
|
changes["pending_request_count"] = len(
|
|
client.pending_server_requests_for_thread(str(expected_thread_id))
|
|
if isinstance(client, AppServerClient) and isinstance(expected_thread_id, str)
|
|
else []
|
|
)
|
|
elif method == "serverRequest/resolved":
|
|
client = self.state.get("client")
|
|
changes["pending_request_count"] = len(
|
|
client.pending_server_requests_for_thread(str(expected_thread_id))
|
|
if isinstance(client, AppServerClient) and isinstance(expected_thread_id, str)
|
|
else []
|
|
)
|
|
if self.on_state_change is not None:
|
|
self.on_state_change(changes, message)
|
|
|
|
def adopt_thread(self, thread: Mapping[str, Any]) -> None:
|
|
"""Switch the tracked logical root after its durable owner adopts a successor."""
|
|
|
|
thread_id = thread.get("id")
|
|
if not isinstance(thread_id, str) or not thread_id:
|
|
raise AppServerError("root successor has no thread identity")
|
|
terminal, active_turn_id = resumed_turns(thread, None)
|
|
turns = [value for value in thread.get("turns", []) if isinstance(value, Mapping)]
|
|
last_turn = turns[-1] if turns else None
|
|
with self.state_lock:
|
|
self.state["thread_id"] = thread_id
|
|
self.state["thread_status"] = thread.get("status")
|
|
goal = thread.get("goal")
|
|
self.state["goal"] = dict(goal) if isinstance(goal, Mapping) else None
|
|
self.state["turn_start_pending"] = False
|
|
self.state["completed_turn"] = terminal
|
|
self.state["turn_failure"] = (
|
|
normalize_turn_failure(terminal) if isinstance(terminal, Mapping) else None
|
|
)
|
|
self.state["active_turn_id"] = active_turn_id
|
|
self.state["last_turn_id"] = (
|
|
terminal.get("id")
|
|
if isinstance(terminal, Mapping) and isinstance(terminal.get("id"), str)
|
|
else active_turn_id
|
|
or (
|
|
last_turn.get("id")
|
|
if isinstance(last_turn, Mapping) and isinstance(last_turn.get("id"), str)
|
|
else None
|
|
)
|
|
)
|
|
if terminal is not None:
|
|
self.turn_event.set()
|
|
else:
|
|
self.turn_event.clear()
|
|
|
|
def open_thread(
|
|
self,
|
|
mode: str,
|
|
params: Mapping[str, Any],
|
|
*,
|
|
timeout: float,
|
|
prior_turn_id: Any = None,
|
|
expected_thread_id: str | None = None,
|
|
interrupt_stale: bool = False,
|
|
) -> dict[str, Any]:
|
|
method = {
|
|
"start": "thread/start",
|
|
"resume": "thread/resume",
|
|
"fork": "thread/fork",
|
|
}.get(mode)
|
|
if method is None:
|
|
raise ValueError(f"unsupported app-server thread mode: {mode}")
|
|
response = self.client.request(method, params, timeout=timeout)
|
|
thread = response.get("thread") if isinstance(response, Mapping) else None
|
|
if not isinstance(thread, Mapping) or not isinstance(thread.get("id"), str):
|
|
raise AppServerError("thread start/resume returned no thread identifier")
|
|
thread_id = str(thread["id"])
|
|
if expected_thread_id is not None and thread_id != expected_thread_id:
|
|
raise AppServerError("thread/resume returned the wrong thread identity")
|
|
with self.state_lock:
|
|
turn_start_pending = bool(self.state.get("turn_start_pending"))
|
|
terminal, active_turn_id = resumed_turns(
|
|
thread,
|
|
prior_turn_id,
|
|
turn_start_pending=turn_start_pending,
|
|
)
|
|
with self.state_lock:
|
|
self.state["thread_id"] = thread_id
|
|
self.state["thread_status"] = thread.get("status")
|
|
goal = thread.get("goal")
|
|
self.state["goal"] = dict(goal) if isinstance(goal, Mapping) else None
|
|
if terminal is not None:
|
|
self.state["completed_turn"] = terminal
|
|
self.state["turn_failure"] = normalize_turn_failure(terminal)
|
|
self.state["active_turn_id"] = None
|
|
self.state["last_turn_id"] = terminal.get("id")
|
|
self.turn_event.set()
|
|
else:
|
|
self.state["turn_failure"] = None
|
|
self.state["active_turn_id"] = active_turn_id
|
|
if active_turn_id is not None:
|
|
self.state["last_turn_id"] = active_turn_id
|
|
self.state["turn_start_pending"] = False
|
|
if terminal is None and interrupt_stale and active_turn_id is not None:
|
|
interrupt_error: AppServerError | None = None
|
|
try:
|
|
self.client.request(
|
|
"turn/interrupt",
|
|
{"threadId": thread_id, "turnId": active_turn_id},
|
|
)
|
|
except AppServerError as exc:
|
|
# The turn may have terminalized after thread/resume but before
|
|
# the interrupt was admitted. Its completion notification, not
|
|
# the racing request error, is authoritative.
|
|
interrupt_error = exc
|
|
if not self.turn_event.wait(
|
|
timeout=min(timeout, APP_SERVER_INITIALIZE_TIMEOUT_SECONDS)
|
|
):
|
|
if interrupt_error is not None:
|
|
raise interrupt_error
|
|
raise AppServerError("persisted active turn did not settle after interruption")
|
|
with self.state_lock:
|
|
settled = self.state.get("completed_turn")
|
|
if not isinstance(settled, Mapping) or settled.get("id") != active_turn_id:
|
|
raise AppServerError("persisted active turn settled with the wrong identity")
|
|
if settled.get("status") != "interrupted":
|
|
return dict(thread)
|
|
with self.state_lock:
|
|
self.state["active_turn_id"] = None
|
|
self.state["completed_turn"] = None
|
|
self.state["turn_failure"] = None
|
|
self.turn_event.clear()
|
|
elif (
|
|
interrupt_stale
|
|
and isinstance(terminal, Mapping)
|
|
and terminal.get("status") == "interrupted"
|
|
):
|
|
with self.state_lock:
|
|
self.state["active_turn_id"] = None
|
|
self.state["completed_turn"] = None
|
|
self.state["turn_failure"] = None
|
|
self.turn_event.clear()
|
|
return dict(thread)
|
|
|
|
def set_goal(
|
|
self,
|
|
*,
|
|
objective: str | None = None,
|
|
status: str | None = None,
|
|
token_budget: int | None = None,
|
|
timeout: float = 60.0,
|
|
) -> dict[str, Any]:
|
|
thread_id = self.thread_id
|
|
if thread_id is None:
|
|
raise AppServerError("app-server host has no persistent thread")
|
|
params: dict[str, Any] = {"threadId": thread_id}
|
|
if objective is not None:
|
|
params["objective"] = objective
|
|
if status is not None:
|
|
params["status"] = status
|
|
if token_budget is not None:
|
|
params["tokenBudget"] = token_budget
|
|
response = self.client.request("thread/goal/set", params, timeout=timeout)
|
|
goal = response.get("goal") if isinstance(response, Mapping) else None
|
|
if not isinstance(goal, Mapping):
|
|
raise AppServerError("thread/goal/set returned no goal")
|
|
with self.state_lock:
|
|
self.state["goal"] = dict(goal)
|
|
return dict(goal)
|
|
|
|
def read_thread(self, *, include_turns: bool = False, timeout: float = 60.0) -> dict[str, Any]:
|
|
thread_id = self.thread_id
|
|
if thread_id is None:
|
|
raise AppServerError("app-server host has no persistent thread")
|
|
response = self.client.request(
|
|
"thread/read",
|
|
{"threadId": thread_id, "includeTurns": include_turns},
|
|
timeout=timeout,
|
|
)
|
|
thread = response.get("thread") if isinstance(response, Mapping) else None
|
|
if not isinstance(thread, Mapping) or thread.get("id") != thread_id:
|
|
raise AppServerError("thread/read returned the wrong thread")
|
|
return dict(thread)
|
|
|
|
def complete_history(self, *, timeout: float = 60.0) -> list[dict[str, Any]]:
|
|
"""Read every persisted turn through the authoritative paginated API."""
|
|
|
|
thread_id = self.thread_id
|
|
if thread_id is None:
|
|
raise AppServerError("app-server host has no persistent thread")
|
|
cursor: str | None = None
|
|
turns: list[dict[str, Any]] = []
|
|
seen_cursors: set[str] = set()
|
|
while True:
|
|
params: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"limit": 100,
|
|
"sortDirection": "asc",
|
|
"itemsView": "full",
|
|
}
|
|
if cursor is not None:
|
|
params["cursor"] = cursor
|
|
response = self.client.request("thread/turns/list", params, timeout=timeout)
|
|
data = response.get("data") if isinstance(response, Mapping) else None
|
|
if not isinstance(data, list) or not all(isinstance(item, Mapping) for item in data):
|
|
raise AppServerError("thread/turns/list returned invalid turn data")
|
|
turns.extend(dict(item) for item in data)
|
|
next_cursor = response.get("nextCursor")
|
|
if next_cursor is None:
|
|
return turns
|
|
if not isinstance(next_cursor, str) or next_cursor in seen_cursors:
|
|
raise AppServerError("thread/turns/list returned an invalid cursor sequence")
|
|
seen_cursors.add(next_cursor)
|
|
cursor = next_cursor
|
|
|
|
def start_turn(
|
|
self,
|
|
input_items: Sequence[Mapping[str, Any]],
|
|
*,
|
|
effort: Any,
|
|
output_schema: Mapping[str, Any] | None = None,
|
|
timeout: float = 60.0,
|
|
) -> str:
|
|
thread_id = self.thread_id
|
|
if thread_id is None:
|
|
raise AppServerError("app-server host has no persistent thread")
|
|
params: dict[str, Any] = {
|
|
"threadId": thread_id,
|
|
"input": [dict(item) for item in input_items],
|
|
"effort": effort,
|
|
}
|
|
if output_schema is not None:
|
|
params["outputSchema"] = dict(output_schema)
|
|
with self.state_lock:
|
|
self.state["active_turn_id"] = None
|
|
self.state["turn_start_pending"] = True
|
|
self.state["completed_turn"] = None
|
|
self.state["turn_failure"] = None
|
|
self.turn_event.clear()
|
|
if self.on_state_change is not None:
|
|
self.on_state_change(
|
|
{"active_turn_id": None, "turn_start_pending": True},
|
|
{"method": "mmo/turn/reset", "params": {"threadId": thread_id}},
|
|
)
|
|
response = self.client.request("turn/start", params, timeout=timeout)
|
|
turn = response.get("turn") if isinstance(response, Mapping) else None
|
|
if not isinstance(turn, Mapping) or not isinstance(turn.get("id"), str):
|
|
raise AppServerError("turn/start returned no turn identifier")
|
|
turn_id = str(turn["id"])
|
|
with self.state_lock:
|
|
completed_turn = self.state.get("completed_turn")
|
|
self.state["last_turn_id"] = turn_id
|
|
self.state["turn_start_pending"] = False
|
|
if isinstance(completed_turn, Mapping) and completed_turn.get("id") == turn_id:
|
|
self.state["active_turn_id"] = None
|
|
changes = {
|
|
"active_turn_id": None,
|
|
"last_turn_id": turn_id,
|
|
"turn_start_pending": False,
|
|
}
|
|
else:
|
|
self.state["active_turn_id"] = turn_id
|
|
changes = {
|
|
"active_turn_id": turn_id,
|
|
"last_turn_id": turn_id,
|
|
"turn_start_pending": False,
|
|
}
|
|
if isinstance(completed_turn, Mapping):
|
|
self.state["completed_turn"] = None
|
|
self.turn_event.clear()
|
|
if self.on_state_change is not None:
|
|
self.on_state_change(
|
|
changes,
|
|
{"method": "mmo/turn/accepted", "params": {"threadId": thread_id}},
|
|
)
|
|
return turn_id
|
|
|
|
def take_completed_turn(self) -> dict[str, Any] | None:
|
|
with self.state_lock:
|
|
value = self.state.get("completed_turn")
|
|
self.state["completed_turn"] = None
|
|
self.turn_event.clear()
|
|
return dict(value) if isinstance(value, Mapping) else None
|
|
|
|
|
|
def retain_partial_evidence(
|
|
data: Mapping[str, Any],
|
|
directory: Path,
|
|
*,
|
|
reason: str,
|
|
events_filename: str = "events.jsonl",
|
|
result_filename: str = "result.md",
|
|
partial_filename: str = "partial-result.md",
|
|
title: str = "Partial agent result",
|
|
) -> dict[str, Any]:
|
|
"""Materialize bounded readable evidence from an interrupted app-server job."""
|
|
|
|
for filename in (events_filename, result_filename, partial_filename):
|
|
if Path(filename).name != filename:
|
|
raise ValueError("partial-evidence filenames must be plain child names")
|
|
partial_path = directory / partial_filename
|
|
if partial_path.is_symlink():
|
|
raise RuntimeError("partial-evidence output cannot traverse a symlink")
|
|
|
|
messages: deque[str] = deque(maxlen=100)
|
|
observations: deque[str] = deque(maxlen=100)
|
|
trace_window_truncated = False
|
|
# Runtime state records include these paths for observability, but evidence
|
|
# recovery uses only the canonical job/session children. A corrupted state
|
|
# file must never turn partial-result recovery into an arbitrary file read.
|
|
events_path = directory / events_filename
|
|
if events_path.is_file() and not events_path.is_symlink():
|
|
with events_path.open("rb") as handle:
|
|
size = events_path.stat().st_size
|
|
start = max(0, size - PARTIAL_EVENT_WINDOW_BYTES)
|
|
if start:
|
|
trace_window_truncated = True
|
|
handle.seek(start - 1)
|
|
if handle.read(1) != b"\n":
|
|
handle.readline()
|
|
for raw in handle:
|
|
try:
|
|
envelope = strict_json_loads(raw)
|
|
except (json.JSONDecodeError, ValueError):
|
|
continue
|
|
pending: list[Any] = [envelope]
|
|
visited = 0
|
|
while pending and visited < 20_000:
|
|
visited += 1
|
|
value = pending.pop()
|
|
if isinstance(value, Mapping):
|
|
if value.get("type") == "agentMessage" and isinstance(
|
|
value.get("text"), str
|
|
):
|
|
message, _truncated = bounded_text(str(value["text"]).strip(), 10_000)
|
|
if message and message not in messages:
|
|
messages.append(message)
|
|
item_type = value.get("type")
|
|
if isinstance(item_type, str) and item_type in {
|
|
"commandExecution",
|
|
"mcpToolCall",
|
|
}:
|
|
observation = json.dumps(
|
|
value,
|
|
ensure_ascii=False,
|
|
sort_keys=True,
|
|
separators=(",", ":"),
|
|
)[:4000]
|
|
if observation not in observations:
|
|
observations.append(observation)
|
|
pending.extend(value.values())
|
|
elif isinstance(value, list):
|
|
pending.extend(value)
|
|
result_path = directory / result_filename
|
|
if result_path.is_file() and not result_path.is_symlink():
|
|
candidate, _truncated = bounded_text(
|
|
result_path.read_text(encoding="utf-8", errors="replace").strip(), 100_000
|
|
)
|
|
if candidate and candidate not in messages:
|
|
messages.append(candidate)
|
|
sections = [
|
|
f"# {title}",
|
|
"",
|
|
f"Terminal reason: {reason}",
|
|
"",
|
|
"This retained evidence is not a contract-valid final result. Review it directly or "
|
|
"continue the persisted thread; it cannot be accepted or integrated as a completed result.",
|
|
]
|
|
if messages:
|
|
sections.extend(["", "## Completed assistant messages", "", "\n\n".join(messages)])
|
|
if observations:
|
|
sections.extend(
|
|
[
|
|
"",
|
|
"## Completed tool observations",
|
|
"",
|
|
"\n".join(f"- `{item}`" for item in observations),
|
|
]
|
|
)
|
|
if trace_window_truncated:
|
|
sections.extend(
|
|
[
|
|
"",
|
|
"Older event records are omitted from this bounded summary; the complete durable "
|
|
"JSONL trace remains available.",
|
|
]
|
|
)
|
|
text = "\n".join(sections).strip() + "\n"
|
|
atomic_write_text(partial_path, text, 0o600)
|
|
return {
|
|
"partial_result_path": str(partial_path),
|
|
"partial_result_sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
|
"partial_message_count": len(messages),
|
|
"partial_tool_observation_count": len(observations),
|
|
"partial_trace_window_truncated": trace_window_truncated,
|
|
"result_kind": "partial",
|
|
"result_state": "unread",
|
|
}
|
|
|
|
|
|
def receive_control_request(connection: socket.socket) -> dict[str, Any]:
|
|
"""Read one bounded newline-framed local control request."""
|
|
|
|
raw = bytearray()
|
|
while b"\n" not in raw:
|
|
chunk = connection.recv(65536)
|
|
if not chunk:
|
|
break
|
|
raw.extend(chunk)
|
|
if len(raw) > MAX_CONTROL_MESSAGE_BYTES:
|
|
raise ValueError("control request exceeds 4 MiB")
|
|
request = strict_json_loads(bytes(raw).split(b"\n", 1)[0])
|
|
if not isinstance(request, dict):
|
|
raise ValueError("control request must be an object")
|
|
return request
|
|
|
|
|
|
def send_control_response(connection: socket.socket, value: Mapping[str, Any]) -> None:
|
|
"""Write one strict newline-framed local control response."""
|
|
|
|
payload = (
|
|
json.dumps(
|
|
dict(value),
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
allow_nan=False,
|
|
).encode("utf-8")
|
|
+ b"\n"
|
|
)
|
|
connection.sendall(payload)
|
|
|
|
|
|
def serve_control_socket(
|
|
socket_path: Path,
|
|
*,
|
|
stop_event: threading.Event,
|
|
handler: Callable[[Mapping[str, Any]], Any],
|
|
on_ready: Callable[[], None],
|
|
backlog: int,
|
|
connection_timeout: float | None,
|
|
) -> None:
|
|
"""Own the shared local control framing while a runner owns action semantics."""
|
|
|
|
if socket_path.exists() or socket_path.is_symlink():
|
|
if socket_path.is_symlink() or not socket_path.is_socket():
|
|
raise RuntimeError("control socket path is unsafe")
|
|
socket_path.unlink()
|
|
try:
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as server:
|
|
server.bind(str(socket_path))
|
|
os.chmod(socket_path, 0o600)
|
|
server.listen(backlog)
|
|
server.settimeout(0.5)
|
|
on_ready()
|
|
while not stop_event.is_set():
|
|
try:
|
|
connection, _ = server.accept()
|
|
except TimeoutError:
|
|
continue
|
|
with connection:
|
|
if connection_timeout is not None:
|
|
connection.settimeout(connection_timeout)
|
|
try:
|
|
result = handler(receive_control_request(connection))
|
|
send_control_response(connection, {"ok": True, "result": result})
|
|
except Exception as exc:
|
|
with contextlib.suppress(OSError):
|
|
send_control_response(
|
|
connection,
|
|
{"ok": False, "error": f"{type(exc).__name__}: {exc}"},
|
|
)
|
|
finally:
|
|
with contextlib.suppress(OSError):
|
|
socket_path.unlink()
|
|
|
|
|
|
def send_control_request(
|
|
socket_path: Path,
|
|
request: Mapping[str, Any],
|
|
*,
|
|
timeout: float = 30.0,
|
|
) -> dict[str, Any]:
|
|
"""Send one bounded request to a worker-owned Unix control socket."""
|
|
|
|
encoded = (
|
|
json.dumps(
|
|
dict(request), ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
|
).encode("utf-8")
|
|
+ b"\n"
|
|
)
|
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
client.settimeout(timeout)
|
|
# A connect failure proves that the request was not delivered. Once the
|
|
# socket is connected, a send/receive failure is ambiguous: the worker
|
|
# may already have applied the mutation before its reply was lost.
|
|
client.connect(str(socket_path))
|
|
try:
|
|
client.sendall(encoded)
|
|
chunks: list[bytes] = []
|
|
total = 0
|
|
while True:
|
|
chunk = client.recv(65536)
|
|
if not chunk:
|
|
break
|
|
chunks.append(chunk)
|
|
total += len(chunk)
|
|
if total > MAX_CONTROL_MESSAGE_BYTES:
|
|
raise ControlDeliveryUnknown("worker control response exceeds 4 MiB")
|
|
if b"\n" in chunk:
|
|
break
|
|
except ControlDeliveryUnknown:
|
|
raise
|
|
except OSError as exc:
|
|
raise ControlDeliveryUnknown(
|
|
f"worker control response was lost after delivery began: {exc}"
|
|
) from exc
|
|
line = b"".join(chunks).split(b"\n", 1)[0]
|
|
if not line:
|
|
raise ControlDeliveryUnknown("worker control socket returned no response")
|
|
try:
|
|
value = strict_json_loads(line)
|
|
except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc:
|
|
raise ControlDeliveryUnknown(f"worker control socket returned invalid JSON: {exc}") from exc
|
|
if not isinstance(value, dict):
|
|
raise ControlDeliveryUnknown("worker control socket returned a non-object response")
|
|
if value.get("ok") is not True:
|
|
raise ControlRequestRejected(str(value.get("error") or "worker control request failed"))
|
|
return value
|