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
35 changes: 27 additions & 8 deletions docs/integrations/ego-source-reader.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# Rendered public-source reading with an existing Ego Page
# Rendered public-source reading with Ego

The optional `loopx.extensions.ego_source_reader` stdio MCP adapter gives an
existing Codex host narrow `read_public_url` and `read_public_image` tools when its model shell cannot
reach Ego's local bootstrap. It reuses an installed Ego browser and an existing
TaskSpace/Page. It does not replace the Chat Session owner or create a browser,
reach Ego's local bootstrap. It reuses an installed Ego browser with a reserved
Page. It does not replace the Chat Session owner or create a browser,
model thread, background service, material catalog or permission authority.

Operator setup is explicit. Reserve an existing Page for this MCP process and
allow only the public-source origins needed for the task. Do not reserve a Page
Operator setup is explicit. Choose `auto` to lazily create one TaskSpace and its
initial `p1` per MCP process. Ego's named factory can reuse an existing space,
so the adapter generates a unique host nonce once and keeps that name throughout
its lifecycle and confirmed-missing recovery. This avoids expired fixed ids and
keeps concurrent hosts on separate Pages. Alternatively, reserve an existing numeric TaskSpace
and Page for this process. Allow only the public-source origins needed for the
task. Do not reserve a Page
shared with another process or grant a private account/admin origin. Navigation
may use the existing browser's session; origin configuration does not prove that
every page on that origin is public. Follow the browser's installed skill for
Expand All @@ -25,18 +30,32 @@ tool_timeout_sec = 40

[mcp_servers.loopx_ego_source_read.env]
LOOPX_EGO_READ_BIN = "/absolute/path/to/installed/ego-browser"
LOOPX_EGO_READ_TASK_SPACE = "7"
LOOPX_EGO_READ_PAGE = "p2"
LOOPX_EGO_READ_TASK_SPACE = "auto"
LOOPX_EGO_READ_PAGE = "p1"
LOOPX_EGO_READ_ORIGINS = "https://example.com,https://www.example.org"
```

Use a supported LoopX installation containing this module. Restart an idle host
through its existing service path and resume the original Session. Do not change
its sandbox, approval policy, workspace grants or authentication to make the
tool work. No new dependency is needed beyond LoopX's existing MCP dependency.
tool work. The Python environment needs the optional `loopx[ego-source-reader]`
extra (`mcp==1.28.1`); the base LoopX CLI has no Python runtime dependencies.
Keep this environment outside PATH when another installation owns `loopx`.
Disable by removing only this MCP entry and restarting that idle host. Keep a
private configuration backup and the installed/source revision for rollback.

In `auto` mode, URL/configuration/origin validation runs before creation.
The process reuses its space for text and image calls, and replaces it once only
when Ego explicitly reports `task space not found`. Other browser errors,
verification walls and user-control stops do not create replacements. An
ambiguous creation receipt fails closed until the operator inspects/restarts the
host. On normal MCP shutdown or SIGTERM, it finishes only its own created,
still-agent-owned space. SIGTERM cleanup can complete while the stdio server
still waits for its host to close stdin; callers should also close the pipe when
stopping the process. Configured numeric spaces are never finished by the
adapter. Shutdown failures may require operator cleanup; a killed process cannot
guarantee cleanup. No login/profile selection or browser-control tool is exposed.

The tool accepts an HTTPS URL, checks the configured origin before navigation,
and uses WHATWG URL normalization for the target before checking the exact
resulting URL before DOM extraction. Equivalent dot segments and query escaping
Expand Down
125 changes: 108 additions & 17 deletions loopx/extensions/ego_source_reader.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Opt-in rendered-source MCP adapter for an existing, reserved Ego Page.
"""Opt-in rendered-source MCP adapter for a reserved Ego Page.

This transport owns no Session, grant, material store or model runner. Operator
configuration selects the browser endpoint and origins; tool input selects only
Expand All @@ -11,11 +11,14 @@
import json
import os
import re
import signal
import subprocess
import threading
import time
import tempfile
import struct
from dataclasses import dataclass
import uuid
from dataclasses import dataclass, replace
from pathlib import Path
from urllib.parse import urlsplit

Expand All @@ -27,6 +30,7 @@
MAX_IMAGE_ITEMS = 128
TIMEOUT_SECONDS = 30
MARKER = "LOOPX_PUBLIC_SOURCE:"
SPACE_MARKER = "LOOPX_READER_SPACE:"
_READ_LOCK = threading.Lock()


Expand All @@ -50,7 +54,7 @@ def _url(value: str) -> tuple[str, str]:
@dataclass(frozen=True)
class ReaderConfig:
executable: str
task_space: int
task_space: int | None
page: str
origins: frozenset[str]

Expand All @@ -59,10 +63,12 @@ def from_environment(cls) -> ReaderConfig:
executable = Path(os.environ["LOOPX_EGO_READ_BIN"]).expanduser()
if not executable.is_absolute() or not executable.is_file():
raise ValueError("configure an installed executable")
space = int(os.environ["LOOPX_EGO_READ_TASK_SPACE"])
page = os.environ["LOOPX_EGO_READ_PAGE"]
if space <= 0 or not re.fullmatch(r"p[1-9][0-9]*", page):
raise ValueError("configure an existing TaskSpace and Page label")
setting = os.environ["LOOPX_EGO_READ_TASK_SPACE"]
space = None if setting == "auto" else int(setting)
page = os.environ.get("LOOPX_EGO_READ_PAGE", "p1")
if ((space is not None and space <= 0) or not re.fullmatch(r"p[1-9][0-9]*", page)
or (space is None and page != "p1")):
raise ValueError("configure an existing Page or auto with p1")
origins = set()
for entry in os.environ["LOOPX_EGO_READ_ORIGINS"].split(","):
canonical, origin = _url(entry.strip())
Expand All @@ -72,6 +78,74 @@ def from_environment(cls) -> ReaderConfig:
return cls(str(executable.resolve(strict=True)), space, page, frozenset(origins))


class _OwnedSpace:
"""One lazily created space per MCP process; never owns configured spaces."""

def __init__(self) -> None:
# Ego's named factory reuses existing agent-owned spaces. A stable
# nonce belongs to this MCP host, not to all hosts of this provider.
self.name = f"LoopX public-source reader {uuid.uuid4().hex}"
self.space: int | None = None
self.executable: str | None = None
self.creation_attempted = False

def resolve(self, config: ReaderConfig, *, deadline: float | None = None) -> ReaderConfig:
if config.task_space is not None:
return config
if self.executable is not None and self.executable != config.executable:
raise ValueError("reader executable changed")
if self.space is None:
# A lost creation receipt is ambiguous: don't create another space
# on the next tool call. An operator must inspect/restart the host.
if self.creation_attempted:
raise ValueError("reader space creation outcome unknown")
self.creation_attempted = True
self.executable = config.executable
script = (f"const t=await taskSpace({json.dumps(self.name)});"
f"console.log({json.dumps(SPACE_MARKER)}+JSON.stringify({{id:t.spaceId}}));")
result = _run(config.executable, script, deadline=deadline)
values = [line[len(SPACE_MARKER):] for line in (result.stdout + "\n" + result.stderr).splitlines()
if line.startswith(SPACE_MARKER)]
if result.returncode or len(values) != 1:
raise ValueError("reader space creation failed")
value = json.loads(values[0])
if not isinstance(value, dict) or type(value.get("id")) is not int or value["id"] <= 0:
raise ValueError("invalid reader space receipt")
self.space = value["id"]
return replace(config, task_space=self.space)

def forget_closed(self) -> None:
self.space = None
self.creation_attempted = False

def close(self) -> None:
if self.space is None or self.executable is None:
return
space, self.space = self.space, None
# Only this process's created space, and only while still agent-owned.
# Never claim/take over a space after the user or another owner stops it.
script = (f"const t=await taskSpace({space});"
"if(t.ownership==='agent')await t.finish({keep:[]});")
try:
_run(self.executable, script)
except (OSError, UnicodeError, subprocess.TimeoutExpired):
pass


_OWNED_SPACE = _OwnedSpace()


def _run(executable: str, script: str, *, deadline: float | None = None) -> subprocess.CompletedProcess[str]:
timeout = TIMEOUT_SECONDS if deadline is None else deadline - time.monotonic()
if timeout <= 0:
raise subprocess.TimeoutExpired(executable, TIMEOUT_SECONDS)
return subprocess.run(
[executable, "nodejs", "-e", script],
stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
timeout=timeout, check=False,
)


def _navigation(config: ReaderConfig, url: str) -> str:
# Use the browser's URL rules before navigation, including dot segments and
# query escaping. The fixed operator-owned script, not page data, supplies
Expand All @@ -80,7 +154,10 @@ def _navigation(config: ReaderConfig, url: str) -> str:
f"const requestedUrl={json.dumps(url)};const target=new URL(requestedUrl);target.hash='';"
f"const origins={json.dumps(sorted(config.origins))}.map(o=>new URL(o).origin);"
"if(!origins.includes(target.origin))throw new Error('source_origin_not_authorized');"
f"const t=await taskSpace({config.task_space});const p=t.page({json.dumps(config.page)});"
f"let t;try{{t=await taskSpace({config.task_space});}}catch(e){{"
"if(/task space not found/i.test(String(e?.message)))"
f"console.log({json.dumps(SPACE_MARKER)}+JSON.stringify({{closed:true}}));throw e;}}"
f"const p=t.page({json.dumps(config.page)});"
"await p.goto(target.href);"
)

Expand Down Expand Up @@ -253,17 +330,21 @@ def _read(url: str, image_index: int | None = None, screenshot_path: str = "") -
if origin not in config.origins:
return {"ok": False, "error": "source_origin_not_authorized"}
# Concurrent calls within this MCP process do not navigate the reserved Page
# over one another. Separate processes must reserve separate existing Pages.
# over one another. Auto mode gives each process a distinct owned space.
if not _READ_LOCK.acquire(blocking=False):
return {"ok": False, "error": "source_reader_busy"}
try:
script = (_script(config, canonical) if image_index is None else
_image_script(config, canonical, image_index, screenshot_path))
result = subprocess.run(
[config.executable, "nodejs", "-e", script],
stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
timeout=TIMEOUT_SECONDS, check=False,
)
deadline = time.monotonic() + TIMEOUT_SECONDS
for attempt in range(2):
resolved = _OWNED_SPACE.resolve(config, deadline=deadline)
script = (_script(resolved, canonical) if image_index is None else
_image_script(resolved, canonical, image_index, screenshot_path))
result = _run(config.executable, script, deadline=deadline)
if (attempt == 0 and config.task_space is None and result.returncode
and SPACE_MARKER + '{"closed":true}' in (result.stdout + "\n" + result.stderr).splitlines()):
_OWNED_SPACE.forget_closed()
continue
break
if result.returncode:
return {"ok": False, "error": "browser_read_failed",
"exit_code": result.returncode}
Expand All @@ -274,6 +355,8 @@ def _read(url: str, image_index: int | None = None, screenshot_path: str = "") -
return {"ok": False, "error": "browser_read_timeout"}
except (OSError, UnicodeError):
return {"ok": False, "error": "browser_read_unavailable"}
except (TypeError, ValueError):
return {"ok": False, "error": "source_reader_space_unavailable"}
finally:
_READ_LOCK.release()

Expand Down Expand Up @@ -319,7 +402,15 @@ def main() -> None:
readOnlyHint=True, destructiveHint=False, idempotentHint=True,
openWorldHint=True,
))(read_public_image)
server.run(transport="stdio")
previous = signal.getsignal(signal.SIGTERM)
def terminate(_signum: int, _frame: object) -> None:
raise SystemExit(0)
signal.signal(signal.SIGTERM, terminate)
try:
server.run(transport="stdio")
finally:
_OWNED_SPACE.close()
signal.signal(signal.SIGTERM, previous)


if __name__ == "__main__":
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ Issues = "https://github.com/loopx-project/loopx/issues"
Changelog = "https://github.com/loopx-project/loopx/releases"

[project.optional-dependencies]
ego-source-reader = [
"mcp==1.28.1",
]
deepseek-harness = [
"deepseek-harness-sdk==0.1.5rc1",
]
Expand Down
Loading
Loading