Files
Mujoco_WASM/decision_server/providers/codex.py
T
chenlin f3a8a38acd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled
feat: release v1.0.1 CADWorld 网站与 LeKiwi 智能抓放
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
2026-09-24 09:57:41 +08:00

483 lines
19 KiB
Python

"""Official version-pinned App Server, ephemeral credentials and offline per-model tool gates.
Only named account/plan operations are exposed by HTTP. The internal RPC transport
is not a proxy. A read-only sandbox alone is never accepted as a no-tools certificate.
"""
import asyncio
import json
import os
import re
import secrets
import shutil
import tempfile
from pathlib import Path
from urllib.parse import urlsplit
from ..protocol import DecisionError, loads, output_schema, validate_plan
from .openai import INSTRUCTIONS, context
VERSION = "codex-cli 0.147.0"
ALLOWED = {
"initialize",
"account/login/start",
"account/login/cancel",
"account/logout",
"account/read",
"model/list",
"account/rateLimits/read",
"config/read",
"thread/start",
"turn/start",
"turn/interrupt",
"thread/unsubscribe",
}
CONFIG = """project_doc_max_bytes = 0
web_search = "disabled"
approval_policy = "never"
sandbox_mode = "read-only"
cli_auth_credentials_store = "ephemeral"
[analytics]
enabled = false
[feedback]
enabled = false
[history]
persistence = "none"
[tools.update_plan]
enabled = false
[tools.experimental_request_user_input]
enabled = false
[features]
apps = false
connectors = false
enable_mcp_apps = false
codex_hooks = false
plugin_hooks = false
hooks = false
skill_search = false
code_mode = false
code_mode_only = false
code_mode_host = false
image_generation = false
computer_use = false
browser_use = false
multi_agent_v2 = false
view_image = false
shell_tool = false
unified_exec = false
multi_agent = false
plugins = false
remote_plugin = false
shell_snapshot = false
skill_mcp_dependency_install = false
"""
def turn_error(error):
info = error.get("codexErrorInfo") if isinstance(error, dict) else None
codes = {
"usageLimitExceeded": "codex_usage_limit_exceeded",
"sessionBudgetExceeded": "codex_session_budget_exceeded",
"unauthorized": "codex_auth_required",
"badRequest": "codex_model_or_request_rejected",
"serverOverloaded": "codex_server_overloaded",
}
code = codes.get(info, "codex_turn_failed") if isinstance(info, str) else "codex_turn_failed"
return DecisionError(code, 502)
class CodexAccount:
def __init__(self, directory: Path, *, probe_url=None):
self.directory = directory
self.probe_url = probe_url # Internal offline fake server only, never supplied over HTTP.
self.probe_token = secrets.token_urlsafe(24) if probe_url else None
self.session_dir = None
self.cwd = None
self.queues = {}
self.checked = set()
self.process = None
self.reader = None
self.pending = {}
self.serial = 0
self.login_id = None
self.login_complete = False
self.login_results = {}
self.lock = asyncio.Lock()
self.account_lock = asyncio.Lock()
async def start(self):
async with self.lock:
if self.process and self.process.returncode is None:
return
binary = shutil.which("codex")
if not binary:
raise DecisionError("codex_not_installed", 409)
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
# Fresh directory per process, never reuse even this application's old auth/config.
if self.session_dir:
await self._close()
self.session_dir = tempfile.TemporaryDirectory(prefix="session-", dir=self.directory)
home = Path(self.session_dir.name) / "home"
cwd = Path(self.session_dir.name) / "workspace"
self.cwd = cwd
home.mkdir(mode=0o700)
cwd.mkdir(mode=0o700)
catalog = home / "models.json"
catalog.write_bytes(Path(__file__).with_name("codex_models_0_147.json").read_bytes())
config = "model_catalog_json = " + json.dumps(str(catalog)) + "\n"
if self.probe_url:
config += 'model_provider = "offline_probe"\n'
else:
config += 'model_provider = "openai"\nforced_login_method = "chatgpt"\n'
config += CONFIG
if self.probe_url:
config += (
'\n[model_providers.offline_probe]\nname = "Offline gate"\n'
"base_url = " + json.dumps(self.probe_url) + "\n"
'wire_api = "responses"\nenv_key = "OFFLINE_PROBE_KEY"\n'
"request_max_retries = 0\nstream_max_retries = 0\n"
)
(home / "config.toml").write_text(config)
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": str(home),
"CODEX_HOME": str(home),
"XDG_CONFIG_HOME": str(home / "config"),
"XDG_CACHE_HOME": str(home / "cache"),
"RUST_LOG": "off",
}
if self.probe_url:
env["OFFLINE_PROBE_KEY"] = self.probe_token
probe = await asyncio.create_subprocess_exec(
binary,
"--version",
env=env,
cwd=cwd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
try:
stdout, _ = await asyncio.wait_for(probe.communicate(), 5)
except BaseException as exc:
if probe.returncode is None:
probe.kill()
await probe.wait()
if isinstance(exc, TimeoutError):
raise DecisionError("codex_version_timeout", 504) from None
raise
if stdout.decode().strip() != VERSION:
raise DecisionError("codex_version_unsupported", 409)
self.process = await asyncio.create_subprocess_exec(
binary,
"app-server",
"--strict-config",
"--listen",
"stdio://",
cwd=cwd,
env=env,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
limit=1048576,
)
self.reader = asyncio.create_task(self._read())
try:
await self.rpc(
"initialize",
{
"clientInfo": {"name": "mujoco-decision", "version": "1"},
"capabilities": {"experimentalApi": False},
},
)
self.process.stdin.write(b'{"method":"initialized"}\n')
await self.process.stdin.drain()
effective = await self.rpc("config/read", {"cwd": str(cwd), "includeLayers": False})
config_data = effective.get("config", {})
if any(config_data.get(key) for key in ("mcp_servers", "hooks", "notify")):
raise DecisionError("codex_inherited_execution_config", 409)
except BaseException:
await self._close()
raise
async def _read(self):
try:
while line := await self.process.stdout.readline():
data = json.loads(line)
if "id" in data and "method" in data:
# No server-initiated tool/approval requests are accepted.
raise DecisionError("codex_unexpected_server_request", 502)
future = self.pending.pop(data.get("id"), None)
if future and not future.done():
if "error" in data:
future.set_exception(DecisionError("codex_rpc_failed", 502))
else:
future.set_result(data.get("result", {}))
params = data.get("params", {})
queue = self.queues.get(params.get("threadId"))
if queue and data.get("method") in ("item/completed", "turn/completed", "error"):
queue.put_nowait(data)
if data.get("method") == "account/login/completed":
params = data.get("params", {})
ident = params.get("loginId")
if isinstance(ident, str):
self.login_results[ident] = params.get("success") is True
if len(self.login_results) > 8:
self.login_results.pop(next(iter(self.login_results)))
if ident == self.login_id:
self.login_complete = params.get("success") is True
self.login_id = None
except (ValueError, OSError, DecisionError, asyncio.QueueFull):
pass
finally:
for future in self.pending.values():
if not future.done():
future.set_exception(DecisionError("codex_process_exited", 502))
self.pending.clear()
for queue in self.queues.values():
if not queue.full():
queue.put_nowait({"method": "error", "params": {}})
if self.process.returncode is None:
self.process.terminate()
async def rpc(self, method, params):
if method not in ALLOWED:
raise DecisionError("codex_rpc_forbidden", 403)
if not self.process or self.process.returncode is not None:
raise DecisionError("codex_not_running", 409)
self.serial += 1
ident = self.serial
future = asyncio.get_running_loop().create_future()
self.pending[ident] = future
try:
self.process.stdin.write(
(json.dumps({"id": ident, "method": method, "params": params}) + "\n").encode()
)
await self.process.stdin.drain()
return await asyncio.wait_for(future, 15)
except TimeoutError:
raise DecisionError("codex_rpc_timeout", 504) from None
except (BrokenPipeError, ConnectionError):
raise DecisionError("codex_process_exited", 502) from None
finally:
self.pending.pop(ident, None)
async def status(self):
await self.start()
account = (await self.rpc("account/read", {"refreshToken": False})).get("account")
return {
"version": VERSION,
"experimental": True,
"storage": "session-only",
"loggedIn": isinstance(account, dict) and account.get("type") == "chatgpt",
"planningAvailable": bool(self.checked),
"checkedModels": sorted(self.checked),
}
async def login(self, *, device=False):
async with self.account_lock:
try:
return await self._login(device=device)
except asyncio.CancelledError:
# A disconnected browser may lose the RPC reply containing loginId.
# Close this isolated process rather than leave an unknown login alive.
await self.close()
raise
async def _login(self, *, device=False):
await self.start()
if self.login_id:
raise DecisionError("codex_login_pending", 409)
value = await self.rpc(
"account/login/start", {"type": "chatgptDeviceCode" if device else "chatgpt"}
)
url = value.get("verificationUrl" if device else "authUrl", "")
if not isinstance(url, str):
await self.close()
raise DecisionError("codex_unexpected_login_url", 502)
parsed = urlsplit(url)
if (
parsed.scheme != "https"
or parsed.hostname != "auth.openai.com"
or parsed.username
or parsed.password
or parsed.port not in (None, 443)
):
await self.close()
raise DecisionError("codex_unexpected_login_url", 502)
self.login_id = value.get("loginId")
if self.login_id in self.login_results:
self.login_complete = self.login_results.pop(self.login_id)
self.login_id = None
if device:
code = value.get("userCode")
if not isinstance(code, str) or not re.fullmatch(r"[A-Za-z0-9-]{4,32}", code):
await self.close()
raise DecisionError("codex_invalid_device_code", 502)
return {"verificationUrl": url, "userCode": code, "storage": "session-only"}
return {"authUrl": url, "storage": "session-only", "planningAvailable": bool(self.checked)}
async def cancel_login(self):
async with self.account_lock:
return await self._cancel_login()
async def _cancel_login(self):
if self.login_id:
try:
await self.rpc("account/login/cancel", {"loginId": self.login_id})
finally:
self.login_id = None
return {"cancelled": True}
async def logout(self):
async with self.account_lock:
try:
if self.process and self.process.returncode is None:
await self._cancel_login()
await self.rpc("account/logout", {})
finally:
await self.close()
return {"loggedIn": False}
async def models(self):
await self.start()
result = await self.rpc("model/list", {"limit": 100, "includeHidden": False})
return {
"models": [
{"id": m["model"], "name": m["displayName"], "default": m["isDefault"]}
for m in result.get("data", [])
if not m.get("hidden")
],
"planningAvailable": bool(self.checked),
}
async def limits(self):
await self.start()
raw = (await self.rpc("account/rateLimits/read", {})).get("rateLimits", {})
result = {"source": "codex-app-server", "primary": None, "secondary": None}
for name in ("primary", "secondary"):
window = raw.get(name) if isinstance(raw, dict) else None
if isinstance(window, dict):
result[name] = {
k: v
for k, v in window.items()
if k in ("usedPercent", "resetsAt", "windowDurationMins")
and type(v) is int
and 0 <= v <= 10**12
}
return result
async def check_model(self, model):
models = await self.models()
if model not in {m["id"] for m in models["models"]}:
raise DecisionError("codex_model_unavailable", 409)
if model not in self.checked:
from .codex_gate import verify_no_tools
await verify_no_tools(self.directory / "gate", model)
self.checked.add(model)
return {"model": model, "toolGatePassed": True, **await self.status()}
async def plan(self, request, model):
if not (await self.status())["loggedIn"]:
raise DecisionError("codex_chatgpt_login_required", 409)
await self.check_model(model)
thread = await self.rpc(
"thread/start",
{
"cwd": str(self.cwd),
"ephemeral": True,
"approvalPolicy": "never",
"sandbox": "read-only",
"model": model,
"modelProvider": "offline_probe" if self.probe_url else "openai",
"baseInstructions": INSTRUCTIONS,
},
)
thread_id = thread["thread"]["id"]
queue = asyncio.Queue(maxsize=32)
self.queues[thread_id] = queue
turn_id = None
complete = False
try:
turn = await self.rpc(
"turn/start",
{
"threadId": thread_id,
"input": [{"type": "text", "text": context(request)}],
"outputSchema": output_schema("Plan"),
},
)
turn_id = turn["turn"]["id"]
outputs = []
async with asyncio.timeout(50):
while True:
event = await queue.get()
params = event["params"]
if params.get("turnId", turn_id) != turn_id:
raise DecisionError("codex_stale_turn", 502)
if event["method"] == "error":
raise turn_error(params.get("error"))
if event["method"] == "item/completed":
item = params["item"]
if item["type"] == "agentMessage":
if item.get("phase") != "commentary":
outputs.append(item["text"])
elif item["type"] not in ("userMessage", "reasoning"):
raise DecisionError("codex_tool_output_forbidden", 502)
elif event["method"] == "turn/completed":
if params["turn"]["id"] != turn_id:
raise DecisionError("codex_stale_turn", 502)
if params["turn"]["status"] != "completed":
raise turn_error(params["turn"].get("error"))
complete = True
break
if len(outputs) != 1 or len(outputs[0]) > 65536:
raise DecisionError("codex_invalid_output", 502)
return validate_plan(loads(outputs[0]), request["remaining"]), {}
finally:
self.queues.pop(thread_id, None)
if not complete:
if turn_id:
try:
async with asyncio.timeout(3):
await self.rpc(
"turn/interrupt", {"threadId": thread_id, "turnId": turn_id}
)
except (DecisionError, asyncio.CancelledError, TimeoutError):
await self.close()
else:
await self.close() # Unknown late turn/start cannot remain alive.
if self.process and self.process.returncode is None:
try:
async with asyncio.timeout(3):
await self.rpc("thread/unsubscribe", {"threadId": thread_id})
except (DecisionError, TimeoutError):
await self.close()
async def close(self):
async with self.lock:
await self._close()
async def _close(self):
if self.process:
if self.process.returncode is None:
self.process.terminate()
try:
await asyncio.wait_for(self.process.wait(), 3)
except TimeoutError:
self.process.kill()
await self.process.wait()
if self.reader:
self.reader.cancel()
await asyncio.gather(self.reader, return_exceptions=True)
self.process = None
self.reader = None
self.login_id = None
self.login_complete = False
self.checked.clear()
self.queues.clear()
self.login_results.clear()
if self.session_dir:
self.session_dir.cleanup()
self.session_dir = None