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
26 changes: 19 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 @@ -112,9 +112,19 @@ inherited variables. Windows execution remains unqualified by the live probes.
The narrowed profile also disables automatic skill catalog injection and project
instruction discovery. The agent can read workspace-owned `AGENTS.md` and skills
through its bounded file tools. Ordinary project Sessions keep native instruction
and skill discovery. This does not disable explicit host skill resolution, clear
old conversation context or suppress account-level instructions supplied by the
host; those remain separate privacy checks.
and skill discovery. Workspace-only native Sessions now also launch with a private
`HOME`/`CODEX_HOME`, a minimal process environment and file-based native credential
storage. Effective MCP servers, including managed and project configuration layers,
are disabled before the thread opens. Core derives the store identity from the
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.
Existing workspace-only Sessions created with a shared home cannot silently
resume or migrate: choose a new Session explicitly after configuring its login.
Ordinary legacy Sessions keep their existing home and exact-thread resume behavior.

The adapter sends the Core-owned named permissions profile, never a simultaneous
legacy sandbox override, on start and exact-thread resume. It verifies the returned
Expand All @@ -124,10 +134,12 @@ permissions. Other executors are rejected for this scope. The native helper uses
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 or isolate host-loaded instructions, skills, MCP
credentials, dynamic tools or a private registry. Those boundaries need their own
qualification before a public group is enabled; a successful file probe is not
public Bot acceptance.
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
privacy/interaction qualification remain required before public enablement; native
context isolation and a successful file probe are prerequisites, not public Bot
acceptance.

Typed Core/HTTP/native-host regressions qualify default writes, explicit read-only,
workspace identity, independent App grants, old-Session rejection and exact-thread
Expand Down
78 changes: 78 additions & 0 deletions loopx/capabilities/native_chat/codex_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""Native Codex process context for the Core-owned project boundary.

This is host adaptation, not another Session or authentication authority.
Ordinary Chat keeps its existing host configuration. Workspace-only Chat uses
an independent native store; no credentials or history are seeded into it.
"""
from __future__ import annotations

import os
from pathlib import Path
from typing import Any, Mapping


def codex_home(base_home: Path, policy: Mapping[str, Any] | None) -> Path:
base = base_home.expanduser().resolve()
key = policy.get("host_store_key") if policy else None
if key is None:
return base
if not isinstance(key, str):
raise ValueError("invalid Core-owned Codex store key")
home = base / "loopx-projects" / key / ".codex"
# An operator's pre-existing symlink must not silently select personal
# skills, authentication or a different upstream thread store.
if home.resolve() != home:
raise ValueError("workspace-only Codex home cannot follow a symlink")
return home


def home_for_project(base_home: Path, context: dict[str, str] | None) -> Path:
from ...control_plane.effect_runtime import effect_runtime_result
policy = effect_runtime_result("collaboration.project.session_identity", {"context": context}) if context else None
return codex_home(base_home, policy)


def require_session_home(base_home: Path, session: Mapping[str, Any]) -> None:
"""A managed upstream thread's store is identity, never a routing override."""
from ...chat_agent import CodexChatAgentError
context = session.get("project_context")
bound_home = session.get("codex_home")
isolated = context is not None and context.get("filesystem_scope") == "workspace_only"
expected = str(home_for_project(base_home, context))
if (bound_home is not None and bound_home != expected) or (isolated and bound_home is None):
raise CodexChatAgentError(
"This managed Session belongs to a different Codex home. Restart LoopX Chat "
"with its original LOOPX_CHAT_CODEX_HOME; do not copy or rebind its history. "
"A workspace-only Session created with shared host context must be explicitly replaced.",
error_code="codex_home_mismatch", gate=None,
)


def process_environment(home: Path, *, isolated: bool) -> dict[str, str]:
if not isolated:
return {**os.environ, "CODEX_HOME": str(home)}
host_home = home.parent
host_home.mkdir(parents=True, exist_ok=True, mode=0o700)
home.mkdir(exist_ok=True, mode=0o700)
env = {"HOME": str(host_home), "CODEX_HOME": str(home), "PATH": os.defpath}
# Windows process startup needs these public OS paths, not the account's
# PATH, provider keys, proxy credentials or other inherited variables.
if os.name == "nt":
env.update({key: os.environ[key] for key in ("SystemRoot", "WINDIR") if key in os.environ})
env["USERPROFILE"] = str(host_home)
return env


def disable_mcp_servers(config: Mapping[str, Any], host_config: Mapping[str, Any]) -> dict[str, Any]:
"""Disable effective native MCP servers, including project/managed layers.

An empty map does not erase lower-layer entries. Only names are forwarded;
commands, environment and credentials remain with the native config owner.
"""
effective = config.get("mcp_servers", {})
requested = host_config.get("mcp_servers", {})
if not isinstance(effective, dict) or not isinstance(requested, dict):
raise ValueError("invalid native MCP server configuration")
return {**host_config, "mcp_servers": {
name: {"enabled": False} for name in effective.keys() | requested.keys()
}}
31 changes: 19 additions & 12 deletions loopx/chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

class CodexChatAgentError(RuntimeError):
def __init__(
self, message: str, *, gate: dict[str, str], error_code: str = "host_gate"
self, message: str, *, gate: dict[str, str] | None, error_code: str = "host_gate"
) -> None:
super().__init__(message)
self.gate = gate
Expand Down Expand Up @@ -250,11 +250,11 @@ def _model_catalog_compatibility_error() -> CodexChatAgentError:


@contextmanager
def _current_builtin_model_catalog(codex_bin: str) -> Iterator[Path]:
def _current_builtin_model_catalog(codex_bin: str, *, environment: dict[str, str] | None = None) -> Iterator[Path]:
"""Materialize the selected Codex binary's current built-in catalog only."""

with tempfile.TemporaryDirectory(prefix="loopx-chat-codex-home-") as codex_home:
env = os.environ.copy()
env = dict(os.environ if environment is None else environment)
env["CODEX_HOME"] = codex_home
try:
result = subprocess.run(
Expand Down Expand Up @@ -627,13 +627,14 @@ def start(
resolved = str(Path(resolved).resolve())
# Pin the host store explicitly, including compatibility retries. Never
# redirect an existing thread by inheriting a different launch context.
runtime_home = (
base_home = (
(codex_home or Path(os.environ.get("CODEX_HOME") or "~/.codex"))
.expanduser()
.resolve()
)
runtime_env = os.environ.copy()
runtime_env["CODEX_HOME"] = str(runtime_home)
from .capabilities.native_chat import codex_context
runtime_home = codex_context.codex_home(base_home, policy if project_context is not None else None)
runtime_env = codex_context.process_environment(runtime_home, isolated=bool(permissions_profile))
# Select an operator-defined native provider for ordinary Chat only.
# Codex owns its configuration/authentication; never copy credentials
# or replace a resumed thread to change its upstream transport.
Expand All @@ -642,6 +643,10 @@ def start(
if not execution_mode else ""
)
command = [resolved, "app-server"]
if permissions_profile:
# Keep this native store independent even when the account's
# default credential backend is a shared OS keychain.
command.extend(["-c", 'cli_auth_credentials_store="file"'])
if _compatibility_catalog_path is not None:
command.extend(
[
Expand Down Expand Up @@ -714,7 +719,7 @@ def start(
read_project_defaults = project_context is not None and bool(resume_thread_id) and (
model is None or reasoning_effort is None
)
if read_project_defaults:
if read_project_defaults or permissions_profile:
# Resume otherwise inherits the old thread's model/effort, even
# when the owner's effective workspace configuration has changed.
# Let Codex resolve trusted layers; never copy sandbox or approval
Expand All @@ -726,10 +731,12 @@ def start(
effective = config_result.get("config", {})
if not isinstance(effective, dict):
raise session._runtime_error("Codex returned an invalid project configuration.")
if permissions_profile:
host_config = codex_context.disable_mcp_servers(effective, host_config or {})
selected = {**effective, **(host_config or {})}
if model is None:
if read_project_defaults and model is None:
model = selected.get("model")
if reasoning_effort is None:
if read_project_defaults and reasoning_effort is None:
reasoning_effort = selected.get("model_reasoning_effort")
if any(value is not None and (not isinstance(value, str) or not value.strip())
for value in (model, reasoning_effort)):
Expand Down Expand Up @@ -807,13 +814,13 @@ def start(
# that public-safe context in each Turn prompt. Codex Goal mode is reserved
# 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 else 3
session.next_request_id = 4 if read_project_defaults or permissions_profile else 3
return session
except _LegacyModelCatalogSchemaError as exc:
session.close()
if _compatibility_catalog_path is not None:
raise _model_catalog_compatibility_error() from exc
with _current_builtin_model_catalog(resolved) as catalog_path:
with _current_builtin_model_catalog(resolved, environment=runtime_env) as catalog_path:
return cls.start(
codex_bin=resolved,
work_dir=root,
Expand All @@ -828,7 +835,7 @@ def start(
runtime_profile=runtime_profile,
sandbox=selected_sandbox,
project_context=project_context,
codex_home=runtime_home,
codex_home=base_home,
model=model,
reasoning_effort=reasoning_effort,
dynamic_tools=dynamic_tools,
Expand Down
15 changes: 4 additions & 11 deletions loopx/chat_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
manager_session_model_allocation,
)
from .chat_coordination import PROJECT_COORDINATION_GUIDANCE, PROJECT_CONTEXT_VERSION, apply_context_handoff
from .capabilities.native_chat import project_context as project_context_policy
from .capabilities.native_chat import codex_context, project_context as project_context_policy
from .control_plane.collaboration import conversation_scope
from .capabilities.manager_runtime import (
load_effective_manager_runtime_profile, manager_runtime_session_fields,
Expand Down Expand Up @@ -690,7 +690,7 @@ def open_session(
upstream_thread_id=adapter.upstream_thread_id,
upstream_mode="chat" if agent_id == "codex" else "default",
channel_id=selected_channel,
codex_home=str(self.codex_home) if agent_id == "codex" else None,
codex_home=str(codex_context.home_for_project(self.codex_home, project_context)) if agent_id == "codex" else None,
project_context=project_context,
steward_context=steward_context,
)
Expand All @@ -717,14 +717,7 @@ def _session_adapter_lock(self, session_id: str) -> threading.Lock:
def _check_codex_home(self, session: dict[str, Any]) -> None:
if session.get("agent_id") != "codex" or session.get("session_mode") == CHAT_SESSION_MODE_ATTACHED:
return
bound_home = session.get("codex_home")
if bound_home is not None and bound_home != str(self.codex_home):
raise CodexChatAgentError(
"This managed Session belongs to a different Codex home. Restart LoopX Chat "
"with its original LOOPX_CHAT_CODEX_HOME; do not copy or rebind its history.",
error_code="codex_home_mismatch",
gate=None,
)
codex_context.require_session_home(self.codex_home, session)

def _ensure_adapter(
self,
Expand Down Expand Up @@ -969,7 +962,7 @@ def _ensure_adapter_locked(
if session.get("agent_id") == "codex" and session.get("codex_home") is None:
# A legacy session is bound only after successful upstream resume,
# not when a service happens to start in a new environment.
self.store.update_session(session_id, codex_home=str(self.codex_home))
self.store.update_session(session_id, codex_home=str(codex_context.home_for_project(self.codex_home, session.get("project_context"))))
if is_manager_channel(session.get("channel_id")):
assert manager_runtime is not None
changes = {
Expand Down
5 changes: 5 additions & 0 deletions loopx/control_plane/collaboration/conversation_scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export function projectConversationIdentity(input: Record<string, unknown>): Rec
const context = normalizeProjectContext(input.context);
const writable = context.grant === "workspace_write";
const permissions = context.filesystem_scope === "workspace_only" ? {
// Share authentication within this authorized workspace/owner binding,
// never with another App or local-owner context. Source topics retain
// separate threads without requiring a new login for every message.
host_store_key: [context.project_ref, ...(context.audience === "bound_owner"
? [context.binding_id, context.provider_ref, context.operator_ref] : ["local"])].join("."),
permissions_profile: `loopx_workspace_only_${writable ? "write" : "read"}`,
host_config: {
default_permissions: `loopx_workspace_only_${writable ? "write" : "read"}`,
Expand Down
15 changes: 15 additions & 0 deletions tests/control_plane_ts/project_conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ test("workspace-only filesystem scope is host-owned, persisted and narrower than
assert.deepEqual(selected.context, scoped);
const identity = projectConversationIdentity({context: selected.context}) as any;
assert.equal(identity.host_config.default_permissions, identity.permissions_profile);
assert.equal(identity.host_store_key, `${context.project_ref}.local`);
const profile = identity.host_config.permissions[identity.permissions_profile];
assert.equal(profile.filesystem[":root"], "deny");
assert.equal(profile.filesystem[":workspace_roots"]["."], grant === "workspace_write" ? "write" : "read");
Expand All @@ -55,4 +56,18 @@ test("workspace-only filesystem scope is host-owned, persisted and narrower than
assert.throws(() => projectConversationIdentity({context: {...context, filesystem_scope: "anything"}}));
assert.equal(projectConversationIdentity({context}).permissions_profile, undefined);
assert.equal(projectConversationIdentity({context}).host_config, undefined);
assert.equal(projectConversationIdentity({context}).host_store_key, undefined);
});

test("private native store follows the authorized workspace and App owner, not a source topic or grant", () => {
const bound = {...context, audience: "bound_owner", filesystem_scope: "workspace_only",
binding_id: "b".repeat(24), provider_ref: "c".repeat(24), operator_ref: "d".repeat(24), source_ref: "e".repeat(24)};
const key = projectConversationIdentity({context: bound}).host_store_key;
assert.equal(key, [bound.project_ref, bound.binding_id, bound.provider_ref, bound.operator_ref].join("."));
for (const patch of [{source_ref: "f".repeat(24)}, {grant: "workspace_write"}]) {
assert.equal(projectConversationIdentity({context: {...bound, ...patch}}).host_store_key, key);
}
for (const field of ["project_ref", "binding_id", "provider_ref", "operator_ref"]) {
assert.notEqual(projectConversationIdentity({context: {...bound, [field]: "f".repeat(24)}}).host_store_key, key);
}
});
Loading
Loading