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
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
483 lines
19 KiB
Python
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
|