Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function PrivateConversationPanel() {
{!loadingGroups && !groups.length && !groupError ? <p>{zh ? "此 App 尚无可见群。先将 Bot 加入调试群,再刷新。" : "This App has no visible groups. Add the Bot to a trial group, then refresh."}</p> : null}
{groupError ? <p role="alert">{groupError}</p> : null}
<button type="button" disabled={busy || loadingGroups || !app} onClick={() => setGroupRefresh(value => value + 1)}>{zh ? "刷新群列表" : "Refresh groups"}</button>
<p>{zh ? "新话题需 @此 Bot;回复留在原话题。仅使用选定工作区,不能继承个人管家、全局工具或已有 Agent 会话。需要在独立执行环境登录;连接成功不代表已通过公开群准出。" : "Mention this Bot to start; replies stay in the original topic. Only the selected workspace is available, without personal steward, global tools or existing Agent Sessions. Sign in to the independent execution environment. Connection is not public release qualification."}</p>
<p>{zh ? "新话题需 @此 Bot;回复留在原话题。工作区、历史和工具保持隔离,模型认证可复用可信宿主账号。连接成功不代表已通过公开群准出。" : "Mention this Bot to start; replies stay in the original topic. Workspace, history and tools remain isolated; model authentication can use the trusted host account. Connection is not public release qualification."}</p>
</fieldset> : null}
<label>{zh ? "角色" : "Role"}<select aria-label={zh ? "私聊角色" : "Private Chat role"} value={role} disabled={busy || audience === "group"} onChange={event => setRole(event.target.value as "project" | "steward")}>
<option value="project">{zh ? "普通项目助手" : "Project assistant"}</option><option value="steward">{zh ? "LoopX 管家(全部已注册工作)" : "LoopX steward (all registered work)"}</option>
Expand Down
27 changes: 20 additions & 7 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,15 @@ Group contexts always use `workspace_only` filesystem isolation, even when the
owner's private project uses `host_default`. Only the selected read/write workspace
grant is available. Personal portfolio, attached Agent selection, global skills,
MCP, shell profiles and inherited account environment are unavailable. The native
host must verify the exact permissions profile and workspace root; its independent
store needs separate login and is never seeded from personal credentials/history.
host must verify the exact permissions profile and workspace root. Its independent
store is never seeded from personal credentials/history; model authentication may
be supplied by the trusted host through the native external-token protocol below.
`/status` exposes the project title, not the host's absolute workspace path.

Core, HTTP, provider-readback and native-protocol fixtures exercise these boundaries.
They are synthetic transport/model evidence, not a live community rollout. The
[community golden queries](../../product/use-cases/community/golden-queries.md)
still require an independently authenticated public workspace and actual pilot
still require an isolated public workspace, qualified model authentication and actual pilot
group journeys before either developer group is enabled. Public-source reading
needs its separately qualified scoped tool; this change does not enable unrestricted
network or personal browser access.
Expand Down Expand Up @@ -160,10 +161,22 @@ authorized workspace and App/owner binding; topics retain independent threads
without requiring a new login for every message. Default `host_default` Sessions
keep the account's existing native configuration and authentication.

No authentication or conversation history is copied into the new store. Log in
through the native Codex flow with `CODEX_HOME` set to the Session's recorded home.
No authentication or conversation history is copied into the new store. An
existing project-native login retains its chosen account. Otherwise, when the
trusted host has native file-based ChatGPT authentication, the adapter supplies
only its access token and account identifier over private app-server stdio using
[`chatgptAuthTokens`](https://learn.chatgpt.com/docs/app-server#3c-log-in-with-externally-managed-chatgpt-tokens-chatgptauthtokens).
This experimental native mode holds tokens in process memory; it does not place
credentials in the project store, model prompt, tool environment or command line.
An unauthorized-token callback asks the native host account store to refresh;
concurrent callbacks reuse an already rotated token. An unavailable host account,
invalid credential store or account change fails closed with a redacted error.
Restoring the host account permits a retry without rebinding the conversation.
Keyring-only and API-key host authentication are not bridged by this adapter.
Independent login remains available through the native Codex flow with
`CODEX_HOME` set to the Session's recorded home.
Existing workspace-only Sessions created with a shared home cannot silently
resume or migrate: choose a new Session explicitly after configuring its login.
resume or migrate: choose a new Session explicitly with its isolated native home.
Ordinary legacy Sessions keep their existing home and exact-thread resume behavior.

The adapter sends the Core-owned named permissions profile, never a simultaneous
Expand All @@ -176,7 +189,7 @@ the canonical Codex executable so a home-directory symlink needs no read grant.
This is a filesystem-tool boundary, not complete community Bot isolation. It does
not authorize group audiences, erase historical context, isolate arbitrary host
dynamic tools or make a checkout containing private files safe to publish. Group
admission, a clean public workspace, supported independent authentication and live
admission, a clean public workspace, qualified model authentication and live
privacy/interaction qualification remain required before public enablement; native
context isolation and a successful file probe are prerequisites, not public Bot
acceptance.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,22 @@ listener、队列或模型 runner。一个 App 仍只有一个 binding owner,
群上下文强制使用 `workspace_only`,不受本人私聊 `host_default` 影响。只保留选定
工作区的读写 grant,不提供个人 portfolio、直连 Agent 选择、全局 skills、MCP、
shell profile 或继承的账号环境。原生宿主必须确认确切 permissions profile 和工作区
根;独立存储需另行登录,不能复制个人凭据或历史。`/status` 只显示项目标题,不展示
根;独立存储不能复制个人凭据或历史,模型认证可由可信宿主提供。`/status` 只显示项目标题,不展示
宿主绝对工作区路径。

Core、HTTP、provider 读回及原生协议 fixture 验证上述边界,属于合成传输/模型证据。
[社区黄金查询](../../product/use-cases/community/golden-queries.md) 仍需独立登录的公开
工作区和真实调试群旅程通过后,才能接入两个正式开发群。公开来源读取另需完成其
[社区黄金查询](../../product/use-cases/community/golden-queries.md) 仍需隔离的公开工作区、
模型认证与真实调试群旅程通过后,才能接入两个正式开发群。公开来源读取另需完成其
限定范围工具的验收;本改动不启用无限制网络或个人浏览器。

已有项目原生登录继续使用其选定账号;否则,可信宿主的原生文件式 ChatGPT 认证
可通过 Codex 实验性 `chatgptAuthTokens` 接口,仅在私有 stdio 中提供 access token
和账号标识。短期认证保留在进程内存,不写入项目存储、模型上下文、工具环境或命令行。
认证失效时,由原生宿主账号存储刷新;并发请求复用已刷新的 token。宿主账号不可用、
凭据损坏或账号变化时返回脱敏错误,恢复宿主账号后可在原 Session 重试。
本 adapter 不桥接仅存于 keyring 的认证或 API key;仍可对项目独立执行原生登录。
该认证路径不扩大工作区、skills、工具或群受众权限,也不代表社区准出已通过。

## 普通工作区读写:默认值与撤权检查点

普通项目 Chat 对宿主声明的工作区默认使用 `workspace_write`。Core context owner
Expand Down
119 changes: 119 additions & 0 deletions loopx/capabilities/native_chat/codex_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Trusted-host model authentication for an isolated native Codex process.

Only short-lived external tokens cross the private app-server stdio boundary.
The native account store owns refresh; no credentials, config or history are
seeded into the project store, tool environment or model context.
"""
from __future__ import annotations

import json
import os
import queue
import subprocess
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


_locks: dict[Path, threading.Lock] = {}
_locks_guard = threading.Lock()


class CodexHostAuthUnavailable(RuntimeError):
def __init__(self) -> None:
super().__init__("Trusted-host Codex model authentication is unavailable.")


def _native_refresh(codex_bin: str, home: Path, *, timeout_sec: float = 8) -> None:
# Account RPCs need no native thread, LoopX Session or model request.
# Reuse the native RPC dispatcher and native credential lifecycle rather
# than implementing OAuth or keeping a second refresh-token cache.
from ...chat_agent import CodexChatAgentSession, _reader

deadline = time.monotonic() + timeout_sec
process = subprocess.Popen(
[codex_bin, "app-server", "-c", 'cli_auth_credentials_store="file"',
"--listen", "stdio://"],
cwd=str(home), env={**os.environ, "CODEX_HOME": str(home)},
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, encoding="utf-8", bufsize=1,
)
messages: queue.Queue = queue.Queue()
session = CodexChatAgentSession(process=process, messages=messages,
thread_id="", work_dir=home, response_timeout_sec=timeout_sec)
try:
threading.Thread(target=_reader, args=(process.stdout, messages), daemon=True).start()
session._request("initialize", {"clientInfo": {
"name": "loopx_chat", "title": "LoopX Chat", "version": "0.1.0"},
"capabilities": {"experimentalApi": True}}, request_id=1)
session._notify("initialized", {})
session.response_timeout_sec = max(0.01, deadline - time.monotonic())
account = session._request("account/read", {"refreshToken": True}, request_id=2)
if (account.get("account") or {}).get("type") != "chatgpt":
raise CodexHostAuthUnavailable()
finally:
session.close()


@dataclass(repr=False)
class CodexHostModelAuth:
home: Path
codex_bin: str
_last_token: str | None = field(default=None, repr=False)

def _read(self) -> dict[str, str]:
path = self.home / "auth.json"
if path.is_symlink():
raise CodexHostAuthUnavailable()
data = json.loads(path.read_text(encoding="utf-8"))
tokens = data.get("tokens")
if data.get("auth_mode") != "chatgpt" or not isinstance(tokens, dict):
raise CodexHostAuthUnavailable()
access, account = tokens.get("access_token"), tokens.get("account_id")
if not all(isinstance(value, str) and value.strip() for value in (access, account)):
raise CodexHostAuthUnavailable()
# Deliberately exclude ID/refresh tokens, email and all other native data.
return {"accessToken": access, "chatgptAccountId": account}

def read(self, *, refresh: bool = False, previous_account_id: str | None = None) -> dict[str, str]:
deadline = time.monotonic() + 8
with _locks_guard:
lock = _locks.setdefault(self.home.resolve(), threading.Lock())
try:
if not lock.acquire(timeout=8):
raise CodexHostAuthUnavailable()
try:
current = self._read()
if previous_account_id is not None and current["chatgptAccountId"] != previous_account_id:
raise CodexHostAuthUnavailable()
# Another project or native client may already have refreshed
# the same account. Consume its new token instead of rotating
# a shared refresh token again for every concurrent callback.
if refresh and current["accessToken"] == self._last_token:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise CodexHostAuthUnavailable()
_native_refresh(self.codex_bin, self.home, timeout_sec=remaining)
current = self._read()
if previous_account_id is not None and current["chatgptAccountId"] != previous_account_id:
raise CodexHostAuthUnavailable()
self._last_token = current["accessToken"]
return current
finally:
lock.release()
except Exception:
# Native errors and malformed private files must never reach the
# Chat transcript, RPC diagnostics or model as exception details.
raise CodexHostAuthUnavailable() from None


def for_isolated_process(base_home: Path, isolated_home: Path, codex_bin: str) -> CodexHostModelAuth | None:
# A separately authenticated project retains its chosen native account.
# Shared host auth is a model-only fallback, never a store identity change.
if (isolated_home / "auth.json").is_symlink():
raise CodexHostAuthUnavailable()
if (isolated_home / "auth.json").exists() or not (base_home / "auth.json").exists():
return None
return CodexHostModelAuth(base_home, codex_bin)
31 changes: 31 additions & 0 deletions loopx/chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,7 @@ class CodexChatAgentSession:
_message_dispatch_lock: threading.Lock = field(
default_factory=threading.Lock, repr=False
)
_host_model_auth: Any = field(default=None, repr=False)

@classmethod
def start(
Expand Down Expand Up @@ -716,6 +717,20 @@ def start(
request_id=1,
)
session._notify("initialized", {})
if permissions_profile:
from .capabilities.native_chat.codex_auth import for_isolated_process
try:
session._host_model_auth = for_isolated_process(base_home, runtime_home, resolved)
if session._host_model_auth is not None:
credentials = session._host_model_auth.read()
login = session._request("account/login/start", {
"type": "chatgptAuthTokens", **credentials}, request_id=4)
if login.get("type") != "chatgptAuthTokens":
raise ValueError("unexpected native model authentication mode")
except Exception:
raise session._runtime_error(
"Trusted-host Codex model authentication is unavailable. "
"Restore the host account and retry this same Session.") from None
read_project_defaults = project_context is not None and bool(resume_thread_id) and (
model is None or reasoning_effort is None
)
Expand Down Expand Up @@ -815,6 +830,8 @@ def start(
# for autonomous execution; enabling it here causes conversational messages
# to be treated as continuation ticks instead of the current user task.
session.next_request_id = 4 if read_project_defaults or permissions_profile else 3
if session._host_model_auth is not None:
session.next_request_id = 5
return session
except _LegacyModelCatalogSchemaError as exc:
session.close()
Expand Down Expand Up @@ -913,6 +930,20 @@ def _next_event(self, *, deadline: float) -> dict[str, Any]:
return message

def _check_server_gate(self, message: dict[str, Any]) -> bool:
if message.get("id") is not None and message.get("method") == "account/chatgptAuthTokens/refresh" and self._host_model_auth is not None:
try:
params = message.get("params") or {}
if not isinstance(params, dict) or params.get("reason") != "unauthorized":
raise ValueError("invalid native refresh request")
previous = params.get("previousAccountId")
if not isinstance(previous, str) or not previous:
raise ValueError("missing native account identity")
result = self._host_model_auth.read(refresh=True, previous_account_id=previous)
self._write({"id": message["id"], "result": result})
except Exception:
self._write({"id": message["id"], "error": {
"code": -32000, "message": "Trusted-host model authentication unavailable."}})
return True
if (
message.get("id") is not None
and message.get("method") == "item/tool/call"
Expand Down
Loading
Loading