Skip to content

Commit a3eec33

Browse files
authored
Merge pull request #6261 from loopx-project/codex/ego-reader-control-race-20261011
fix(ego-source-reader): preserve native control stops when CLI output is discarded
2 parents 135bb67 + e9c1387 commit a3eec33

3 files changed

Lines changed: 70 additions & 6 deletions

File tree

‎docs/integrations/ego-source-reader.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,14 @@ navigating or capturing it. Once Ego returns Agent control, the next call uses
9595
the same space automatically; no extra chat confirmation is needed. This tool
9696
does not force control, poll in the background or grant approval for protected
9797
actions. Browser failures and rendering timeouts alone are not requests for
98-
human handoff. A source's actual login or verification requirement remains a
98+
human handoff. If control changes after inventory, Ego's native boundary still
99+
stops the operation. Its hard-stop output sink can discard console sentinels;
100+
the adapter preserves only the stable user-control/inactive error code in a
101+
private temporary file and returns `source_reader_not_agent_owned`. It discards
102+
partial output, makes no replacement or takeover attempt, and removes that
103+
file after the call. Ordinary failures remain ordinary failures. This error
104+
transport does not grant control or prove a live human handoff was tested.
105+
A source's actual login or verification requirement remains a
99106
separate boundary; follow the installed Ego skill and continue independent work.
100107

101108
On normal MCP shutdown or SIGTERM, it finishes only its own created,

‎loopx/extensions/ego_source_reader.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,30 @@ def _run(executable: str, script: str, *, deadline: float | None = None) -> subp
187187
timeout = TIMEOUT_SECONDS if deadline is None else deadline - time.monotonic()
188188
if timeout <= 0:
189189
raise subprocess.TimeoutExpired(executable, TIMEOUT_SECONDS)
190-
return subprocess.run(
191-
[executable, "nodejs", "-e", script],
192-
stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
193-
timeout=timeout, check=False,
194-
)
190+
# Ego discards console output on a native control stop, including our
191+
# sentinel. Preserve only its stable code outside that buffered channel.
192+
with tempfile.TemporaryDirectory(prefix="loopx-source-control-") as directory:
193+
stop = Path(directory) / "stop.txt"
194+
wrapped = (
195+
"try{" + script + "}catch(e){"
196+
"if(['EGO_TASK_SPACE_USER_IN_CONTROL','EGO_TASK_SPACE_INACTIVE']"
197+
".includes(e?.error_code)){"
198+
f"(await import('node:fs')).writeFileSync({json.dumps(str(stop))},e.error_code);"
199+
"}throw e;}"
200+
)
201+
result = subprocess.run(
202+
[executable, "nodejs", "-e", wrapped],
203+
stdin=subprocess.DEVNULL, capture_output=True, text=True, encoding="utf-8",
204+
timeout=timeout, check=False,
205+
)
206+
if stop.exists() and stop.read_text(encoding="utf-8") in {
207+
"EGO_TASK_SPACE_USER_IN_CONTROL", "EGO_TASK_SPACE_INACTIVE",
208+
}:
209+
return subprocess.CompletedProcess(
210+
result.args, 1,
211+
MARKER + '{"error":"source_reader_not_agent_owned"}', "",
212+
)
213+
return result
195214

196215

197216
def _navigation(config: ReaderConfig, url: str, *, image: bool = False) -> str:

‎tests/test_ego_source_reader.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,44 @@ def run(_executable, script, **_kwargs):
404404
assert observations == [["resolve", "finish"] if ownership == "agent" else []]
405405

406406

407+
@pytest.mark.parametrize("code,expected", [
408+
("EGO_TASK_SPACE_USER_IN_CONTROL", "source_reader_not_agent_owned"),
409+
("EGO_TASK_SPACE_INACTIVE", "source_reader_not_agent_owned"),
410+
("EGO_OPERATION_FAILED", "browser_read_failed"),
411+
])
412+
@pytest.mark.parametrize("stage", ["resolve", "navigate", "extract"])
413+
@pytest.mark.parametrize("image", [False, True])
414+
def test_native_control_race_survives_discarded_cli_output(
415+
configured, monkeypatch, code, expected, stage, image,
416+
):
417+
# Inventory still says Agent, but a later SDK boundary denies control.
418+
# Run the production script in a real child. Model Ego's hard-stop sink,
419+
# which discards all result markers and does not print error_code.
420+
real_run = subprocess.run
421+
trace = []
422+
def run(args, **kwargs):
423+
harness = (
424+
"const code=" + json.dumps(code) + ";const stage=" + json.dumps(stage) + ";"
425+
"function denied(){const e=Error('native wording changed');e.error_code=code;throw e;}"
426+
"async function listTaskSpaces(){return [{id:7,ownership:'agent'}];}"
427+
"async function taskSpace(){if(stage==='resolve')denied();return {page(){return {"
428+
"async goto(){if(stage==='navigate')denied();},async waitForFunction(){},"
429+
"async evaluate(){denied();},async screenshot(){throw Error('capture forbidden');}};}};}"
430+
"(async()=>{" + args[3] + "})().catch(()=>{process.exitCode=1;});"
431+
)
432+
child = real_run(["node", "-e", harness], **kwargs)
433+
trace.append(child.returncode)
434+
# Neither code nor an extraction sentinel survives the real SDK sink.
435+
return subprocess.CompletedProcess(args, child.returncode, "", "SDK hard stop guidance")
436+
monkeypatch.setattr(reader.subprocess, "run", run)
437+
if image:
438+
result = json.loads(reader.read_public_image(URL, 0)[0].text)
439+
else:
440+
result = reader.read_public_url(URL)
441+
assert result["ok"] is False and result["error"] == expected
442+
assert trace == [1] # No retry, new space, claim, takeover, or capture.
443+
444+
407445
def test_creation_and_read_share_one_timeout_budget(configured, monkeypatch):
408446
auto_config(monkeypatch)
409447
clock = iter([0, 5, 29])

0 commit comments

Comments
 (0)