@melaya/runner 1.1.80 → 1.1.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assistantHost.py +238 -1
- package/dist/connection.js +23 -0
- package/dist/excomGate.d.ts +32 -0
- package/dist/excomGate.js +100 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -282,6 +282,7 @@ _DELTA_FLUSH_MS = 35
|
|
|
282
282
|
|
|
283
283
|
# Sentinel returned by a turn's coroutine when the user pressed STOP.
|
|
284
284
|
_CANCELLED = object()
|
|
285
|
+
_DEFERRED = object() # EXCOM: the turn ended on an approval request
|
|
285
286
|
|
|
286
287
|
|
|
287
288
|
class _RedactingStdout:
|
|
@@ -567,7 +568,8 @@ def _apply_turn_update(req) -> None:
|
|
|
567
568
|
if not tid or tid != _stream.get("turnId"):
|
|
568
569
|
return
|
|
569
570
|
changed = False
|
|
570
|
-
|
|
571
|
+
# An EXCOM turn keeps the server gate: a mode change never re-arms the card.
|
|
572
|
+
if "hitl_mode" in req and not _EXCOM_GATE.get("on"):
|
|
571
573
|
_apply_hitl_mode(req.get("hitl_mode"))
|
|
572
574
|
_log(f"autonomy changed mid-turn -> {os.environ.get('MEL_ASSISTANT_HITL_MODE')}")
|
|
573
575
|
changed = True
|
|
@@ -650,6 +652,15 @@ def _autonomy_block() -> str:
|
|
|
650
652
|
approval_required result and only THEN pauses. Refreshed every turn so a
|
|
651
653
|
safe<->autonomous flip takes effect on the next message (the tool gate env is
|
|
652
654
|
flipped in the same turn by _apply_hitl_mode)."""
|
|
655
|
+
if _EXCOM_GATE.get("on"):
|
|
656
|
+
# EXCOM executive: its autonomy is the server's gate, not this mode.
|
|
657
|
+
return (
|
|
658
|
+
"\n\n## AUTONOMY: decided by the executive gate\n"
|
|
659
|
+
"Every tool call is checked by the server against your autonomy, grants and rules "
|
|
660
|
+
"before it runs. A refused call returns the reason: do not retry it, adjust or propose "
|
|
661
|
+
"it as a task for the humans. A call that needs a human returns approval_pending: "
|
|
662
|
+
"stop there, the turn ends and resumes once a human decides.\n"
|
|
663
|
+
)
|
|
653
664
|
mode = (os.environ.get("MEL_ASSISTANT_HITL_MODE", "safe") or "safe").lower()
|
|
654
665
|
if mode == "autonomous":
|
|
655
666
|
return (
|
|
@@ -735,6 +746,184 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
|
|
|
735
746
|
pass
|
|
736
747
|
|
|
737
748
|
|
|
749
|
+
# ── EXCOM gate (server-authoritative tool gate) ─────────────────────────────
|
|
750
|
+
# An EXCOM executive whose model runs here gets exactly the gate it has on a
|
|
751
|
+
# cloud model: before EVERY tool call of its turn this host asks the server
|
|
752
|
+
# (stdout frame `excom_gate` {gateId, tool, args} -> runner -> server, which
|
|
753
|
+
# evaluates the turn's own constraint) and waits for the answer on stdin
|
|
754
|
+
# (`excom_gate_reply` {gateId, decision, message, requestId, cached, report}):
|
|
755
|
+
# allow -> run the tool, then report its metadata (`excom_gate_result`
|
|
756
|
+
# {gateId, ok, chars, uncertain}; plus `content` when the server
|
|
757
|
+
# asked for it: vault reads, for the internal-excerpt guard);
|
|
758
|
+
# `cached` -> answer with that text, nothing runs;
|
|
759
|
+
# deny -> the tool does not run; the model sees the server's reason;
|
|
760
|
+
# ask -> the approval card is staged server side; the model sees
|
|
761
|
+
# approval_pending and the turn ends (the job parks on it).
|
|
762
|
+
# Fail closed: no answer within MEL_EXCOM_GATE_TIMEOUT_S (default 30 s), a
|
|
763
|
+
# malformed answer, or a gate that cannot be installed => nothing runs. The
|
|
764
|
+
# runner's own Safe-mode card is not part of it: the server decides.
|
|
765
|
+
_EXCOM_GATE: dict = {
|
|
766
|
+
"on": False, "turnId": "", "installed": False, "deferred": None,
|
|
767
|
+
"lock": threading.Lock(), "waiters": {},
|
|
768
|
+
}
|
|
769
|
+
_EXCOM_GATE_CONTENT_MAX = 64_000
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _excom_gate_timeout() -> float:
|
|
773
|
+
try:
|
|
774
|
+
return max(2.0, min(120.0, float(os.environ.get("MEL_EXCOM_GATE_TIMEOUT_S", "30") or 30)))
|
|
775
|
+
except Exception:
|
|
776
|
+
return 30.0
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _excom_gate_deny(gate_id: str, message: str) -> dict:
|
|
780
|
+
return {"gateId": gate_id, "decision": "deny", "message": message}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _excom_gate_ask(tool: str, args) -> dict:
|
|
784
|
+
"""Blocking: ask the server for a decision on one tool call (worker thread)."""
|
|
785
|
+
gid = _uuid.uuid4().hex
|
|
786
|
+
ev = threading.Event()
|
|
787
|
+
slot = {"ev": ev, "reply": None}
|
|
788
|
+
with _EXCOM_GATE["lock"]:
|
|
789
|
+
_EXCOM_GATE["waiters"][gid] = slot
|
|
790
|
+
try:
|
|
791
|
+
_emit(str(_EXCOM_GATE.get("turnId") or ""), "excom_gate", gateId=gid, tool=str(tool or ""),
|
|
792
|
+
args=args if isinstance(args, dict) else {})
|
|
793
|
+
if not ev.wait(_excom_gate_timeout()):
|
|
794
|
+
return _excom_gate_deny(gid, "The executive gate did not answer in time, so nothing ran. Try again later or propose it as a task for the humans.")
|
|
795
|
+
reply = slot["reply"]
|
|
796
|
+
finally:
|
|
797
|
+
with _EXCOM_GATE["lock"]:
|
|
798
|
+
_EXCOM_GATE["waiters"].pop(gid, None)
|
|
799
|
+
if not isinstance(reply, dict) or reply.get("gateId") != gid or reply.get("decision") not in ("allow", "deny", "ask"):
|
|
800
|
+
return _excom_gate_deny(gid, "The executive gate answered something unreadable, so nothing ran.")
|
|
801
|
+
return reply
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _excom_gate_deliver(reply) -> bool:
|
|
805
|
+
"""stdin-reader thread: hand a server answer to the waiting call."""
|
|
806
|
+
if not isinstance(reply, dict):
|
|
807
|
+
return False
|
|
808
|
+
gid = str(reply.get("gateId") or "")
|
|
809
|
+
with _EXCOM_GATE["lock"]:
|
|
810
|
+
slot = _EXCOM_GATE["waiters"].get(gid)
|
|
811
|
+
if not slot:
|
|
812
|
+
return False
|
|
813
|
+
slot["reply"] = reply
|
|
814
|
+
slot["ev"].set()
|
|
815
|
+
return True
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _excom_gate_release_all() -> None:
|
|
819
|
+
"""Turn end / channel loss: every waiting call is denied (fail closed)."""
|
|
820
|
+
with _EXCOM_GATE["lock"]:
|
|
821
|
+
slots = list(_EXCOM_GATE["waiters"].items())
|
|
822
|
+
for gid, slot in slots:
|
|
823
|
+
slot["reply"] = _excom_gate_deny(gid, "The executive gate is gone, so nothing ran.")
|
|
824
|
+
slot["ev"].set()
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _excom_gate_report(gate_id: str, ok: bool, text: str, uncertain: bool, with_content: bool) -> None:
|
|
828
|
+
fields = {"gateId": gate_id, "ok": bool(ok), "chars": len(text or ""), "uncertain": bool(uncertain)}
|
|
829
|
+
if with_content and ok:
|
|
830
|
+
fields["content"] = (text or "")[:_EXCOM_GATE_CONTENT_MAX]
|
|
831
|
+
_emit(str(_EXCOM_GATE.get("turnId") or ""), "excom_gate_result", **fields)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _excom_text_response(text: str):
|
|
835
|
+
from agentscope.tool import ToolResponse
|
|
836
|
+
from agentscope.message import TextBlock
|
|
837
|
+
return ToolResponse(content=[TextBlock(type="text", text=str(text))])
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _excom_response_text(resp) -> str:
|
|
841
|
+
try:
|
|
842
|
+
content = getattr(resp, "content", None) or []
|
|
843
|
+
parts = []
|
|
844
|
+
for b in content:
|
|
845
|
+
t = b.get("text") if isinstance(b, dict) else getattr(b, "text", None)
|
|
846
|
+
if isinstance(t, str):
|
|
847
|
+
parts.append(t)
|
|
848
|
+
return "\n".join(parts)
|
|
849
|
+
except Exception:
|
|
850
|
+
return ""
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _excom_looks_failed(text: str) -> bool:
|
|
854
|
+
head = (text or "").lstrip()[:80].lower()
|
|
855
|
+
return head.startswith(("error", "could not", "failed", "functionnotfounderror", "tool error"))
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
async def _excom_gate_middleware(kwargs, next_handler):
|
|
859
|
+
"""agentscope Toolkit middleware: the gate around every tool call."""
|
|
860
|
+
if not _EXCOM_GATE.get("on"):
|
|
861
|
+
async for chunk in next_handler(**kwargs):
|
|
862
|
+
yield chunk
|
|
863
|
+
return
|
|
864
|
+
import asyncio
|
|
865
|
+
tc = kwargs.get("tool_call") or {}
|
|
866
|
+
name = str((tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")) or "")
|
|
867
|
+
args = (tc.get("input") if isinstance(tc, dict) else getattr(tc, "input", None)) or {}
|
|
868
|
+
if _EXCOM_GATE.get("deferred"):
|
|
869
|
+
yield _excom_text_response("Not run: this turn is waiting for a human approval and ends now.")
|
|
870
|
+
return
|
|
871
|
+
reply = await asyncio.to_thread(_excom_gate_ask, name, args)
|
|
872
|
+
decision = reply.get("decision")
|
|
873
|
+
if decision == "ask":
|
|
874
|
+
yield _excom_text_response(str(reply.get("message") or json.dumps({"status": "approval_pending", "requestId": reply.get("requestId"), "tool": name})))
|
|
875
|
+
# The turn ends after this round, like the cloud loop (the job parks).
|
|
876
|
+
_EXCOM_GATE["deferred"] = {"requestId": str(reply.get("requestId") or ""), "tool": name}
|
|
877
|
+
return
|
|
878
|
+
if decision != "allow":
|
|
879
|
+
yield _excom_text_response(str(reply.get("message") or "Outside this executive's autonomy. Nothing ran."))
|
|
880
|
+
return
|
|
881
|
+
if isinstance(reply.get("cached"), str):
|
|
882
|
+
yield _excom_text_response(reply["cached"])
|
|
883
|
+
return
|
|
884
|
+
gid = str(reply.get("gateId") or "")
|
|
885
|
+
is_write = reply.get("isWrite") is True
|
|
886
|
+
last = None
|
|
887
|
+
try:
|
|
888
|
+
async for chunk in next_handler(**kwargs):
|
|
889
|
+
last = chunk
|
|
890
|
+
yield chunk
|
|
891
|
+
except BaseException:
|
|
892
|
+
# Cancelled, timed out or raised: a write may have happened.
|
|
893
|
+
_excom_gate_report(gid, False, "", is_write, False)
|
|
894
|
+
raise
|
|
895
|
+
text = _excom_response_text(last)
|
|
896
|
+
failed = _excom_looks_failed(text)
|
|
897
|
+
_excom_gate_report(gid, not failed, text, failed and is_write and "timeout" in text.lower(), reply.get("report") == "content")
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _install_excom_gate(toolkit) -> bool:
|
|
901
|
+
try:
|
|
902
|
+
if toolkit is None or not hasattr(toolkit, "register_middleware"):
|
|
903
|
+
return False
|
|
904
|
+
toolkit.register_middleware(_excom_gate_middleware)
|
|
905
|
+
return True
|
|
906
|
+
except Exception as exc:
|
|
907
|
+
_log(f"excom gate middleware unavailable: {type(exc).__name__}")
|
|
908
|
+
return False
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
def _excom_gate_begin(turn_id: str) -> None:
|
|
912
|
+
_EXCOM_GATE["on"] = True
|
|
913
|
+
_EXCOM_GATE["turnId"] = turn_id
|
|
914
|
+
_EXCOM_GATE["deferred"] = None
|
|
915
|
+
# The server decides every call: the host's own Safe-mode card stays out
|
|
916
|
+
# (lazy_registry reads the mode at call time).
|
|
917
|
+
_apply_hitl_mode("autonomous")
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _excom_gate_end() -> None:
|
|
921
|
+
_EXCOM_GATE["on"] = False
|
|
922
|
+
_EXCOM_GATE["turnId"] = ""
|
|
923
|
+
_EXCOM_GATE["deferred"] = None
|
|
924
|
+
_excom_gate_release_all()
|
|
925
|
+
|
|
926
|
+
|
|
738
927
|
# ── Output files (assistant protocol 4) ──────────────────────────────────────
|
|
739
928
|
# A turn frame may carry `output_dir`: a fresh folder the runner made for THIS
|
|
740
929
|
# turn. It becomes MELAYA_OUTPUT_DIR for the turn, so every file-producing tool
|
|
@@ -1096,6 +1285,11 @@ def _build_agent():
|
|
|
1096
1285
|
except Exception:
|
|
1097
1286
|
toolkit = build_toolkit(names=[])
|
|
1098
1287
|
|
|
1288
|
+
# EXCOM gate: the server decides every tool of an executive's turn. The
|
|
1289
|
+
# middleware is a no-op on every other turn; without it an EXCOM turn is
|
|
1290
|
+
# refused (fail closed, see the turn loop in main()).
|
|
1291
|
+
_EXCOM_GATE["installed"] = _install_excom_gate(toolkit)
|
|
1292
|
+
|
|
1099
1293
|
phone_rule = (
|
|
1100
1294
|
"- If the user asks you to DO something on their phone (open an app, browse, "
|
|
1101
1295
|
"tap, type, comment, post), use the phone_* tools. ALWAYS call "
|
|
@@ -1704,6 +1898,15 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
1704
1898
|
except BaseException: # CancelledError + any teardown error
|
|
1705
1899
|
pass
|
|
1706
1900
|
return _CANCELLED
|
|
1901
|
+
# EXCOM: a call went to a human approval; the turn ends after
|
|
1902
|
+
# this round (the job parks), like the cloud loop.
|
|
1903
|
+
if _EXCOM_GATE.get("on") and _EXCOM_GATE.get("deferred"):
|
|
1904
|
+
task.cancel()
|
|
1905
|
+
try:
|
|
1906
|
+
await task
|
|
1907
|
+
except BaseException:
|
|
1908
|
+
pass
|
|
1909
|
+
return _DEFERRED
|
|
1707
1910
|
if wallclock_s and time.time() - started_at > wallclock_s:
|
|
1708
1911
|
task.cancel()
|
|
1709
1912
|
try:
|
|
@@ -1742,6 +1945,16 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
1742
1945
|
_emit(turn_id, "done")
|
|
1743
1946
|
return
|
|
1744
1947
|
|
|
1948
|
+
if result is _DEFERRED:
|
|
1949
|
+
_flush_delta(turn_id)
|
|
1950
|
+
_stream["turnId"] = ""
|
|
1951
|
+
pend = _EXCOM_GATE.get("deferred") or {}
|
|
1952
|
+
_log("turn ends on an EXCOM approval request")
|
|
1953
|
+
_emit(turn_id, "text", content=f"Waiting for a human approval of {pend.get('tool') or 'an action'}. The work continues once it is decided.")
|
|
1954
|
+
_emit_turn_files(turn_id)
|
|
1955
|
+
_emit(turn_id, "done")
|
|
1956
|
+
return
|
|
1957
|
+
|
|
1745
1958
|
if result is _WALLCLOCK:
|
|
1746
1959
|
_stream["turnId"] = ""
|
|
1747
1960
|
_log("browser turn wall-clock budget exceeded")
|
|
@@ -1789,6 +2002,11 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
|
1789
2002
|
if not tid or tid == _stream.get("turnId"):
|
|
1790
2003
|
_stream["cancel"] = True
|
|
1791
2004
|
continue # consumed — do not queue as a turn
|
|
2005
|
+
# EXCOM gate answer: wakes the tool call waiting on it, WHILE
|
|
2006
|
+
# the turn runs. Never queued as a turn.
|
|
2007
|
+
if isinstance(req, dict) and req.get("kind") == "excom_gate_reply":
|
|
2008
|
+
_excom_gate_deliver(req)
|
|
2009
|
+
continue
|
|
1792
2010
|
# Live mid-turn update (autonomy mode / connectors): applied HERE,
|
|
1793
2011
|
# while the turn runs, like STOP. Never queued as a turn.
|
|
1794
2012
|
if isinstance(req, dict) and req.get("kind") == "turn_update":
|
|
@@ -1801,6 +2019,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
|
|
|
1801
2019
|
except Exception:
|
|
1802
2020
|
pass
|
|
1803
2021
|
finally:
|
|
2022
|
+
_excom_gate_release_all() # no answer can come any more: deny (fail closed)
|
|
1804
2023
|
q.put("") # sentinel: stdin closed
|
|
1805
2024
|
|
|
1806
2025
|
|
|
@@ -1958,6 +2177,20 @@ def main() -> int:
|
|
|
1958
2177
|
# it here (before _run_turn) is enough — no agent rebuild needed.
|
|
1959
2178
|
if "hitl_mode" in req:
|
|
1960
2179
|
_apply_hitl_mode(req.get("hitl_mode"))
|
|
2180
|
+
# EXCOM gate: the server decides every tool of this turn. A host that
|
|
2181
|
+
# cannot install the gate refuses the turn (fail closed).
|
|
2182
|
+
excom_gate = bool(req.get("excom_gate"))
|
|
2183
|
+
if excom_gate and not _EXCOM_GATE.get("installed"):
|
|
2184
|
+
_log("excom gate requested but unavailable: turn refused")
|
|
2185
|
+
_emit(turn_id, "error", code="runner_constraint_unsupported", message=(
|
|
2186
|
+
"This runner cannot enforce the executive's autonomy gate, so nothing ran. "
|
|
2187
|
+
"Update the runner, or pick a cloud model for this executive."))
|
|
2188
|
+
_emit(turn_id, "done")
|
|
2189
|
+
continue
|
|
2190
|
+
if excom_gate:
|
|
2191
|
+
_excom_gate_begin(turn_id)
|
|
2192
|
+
else:
|
|
2193
|
+
_EXCOM_GATE["on"] = False
|
|
1961
2194
|
# Product usage attribution is per turn. This must be applied before the
|
|
1962
2195
|
# AgentScope spans are created; _emit_usage force-flushes them before the
|
|
1963
2196
|
# next turn can replace the value.
|
|
@@ -1974,6 +2207,8 @@ def main() -> int:
|
|
|
1974
2207
|
_apply_static_context(agent, base_sys_prompt, req.get("static_context"))
|
|
1975
2208
|
if not message and not images:
|
|
1976
2209
|
_purge_connector_env()
|
|
2210
|
+
if excom_gate:
|
|
2211
|
+
_excom_gate_end()
|
|
1977
2212
|
_emit(turn_id, "done")
|
|
1978
2213
|
continue
|
|
1979
2214
|
# P2-6: nudge the live memory budget down if a prior turn's OOM downgraded
|
|
@@ -1989,6 +2224,8 @@ def main() -> int:
|
|
|
1989
2224
|
try:
|
|
1990
2225
|
_run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
|
|
1991
2226
|
finally:
|
|
2227
|
+
if excom_gate:
|
|
2228
|
+
_excom_gate_end()
|
|
1992
2229
|
_clear_turn_output_dir()
|
|
1993
2230
|
_purge_browser_turn_grant()
|
|
1994
2231
|
# Connector credentials live for the turn only; the selection (ids,
|
package/dist/connection.js
CHANGED
|
@@ -57,6 +57,7 @@ import { reportHostFile, reportOutputsSince } from "./hostFiles.js";
|
|
|
57
57
|
// Computer control: computer:command / computer:stop relayed to the desktop app
|
|
58
58
|
// executor (or the CLI computer host); capabilities reported on connect.
|
|
59
59
|
import { attachComputerRelay } from "./computerRelay.js";
|
|
60
|
+
import { EXCOM_GATE_VERSION, relayExcomGate, relayExcomGateResult } from "./excomGate.js";
|
|
60
61
|
// Model rediscovery while connected (audit P2.3): every 15 min, and skipped
|
|
61
62
|
// when the provider fingerprint (credential/cache file mtimes + local server
|
|
62
63
|
// ports) is unchanged since the last scan, unless that scan is older than
|
|
@@ -613,6 +614,11 @@ export async function connect(opts) {
|
|
|
613
614
|
package: "@melaya/runner",
|
|
614
615
|
version: RUNNER_VERSION,
|
|
615
616
|
assistantProtocol: ASSISTANT_PROTOCOL_VERSION,
|
|
617
|
+
// EXCOM gate (excomGate.ts): this runner's host asks the server before
|
|
618
|
+
// every tool of an EXCOM executive's turn and fails closed without an
|
|
619
|
+
// answer. The server allows Prepare, Run and vault writes on a runner
|
|
620
|
+
// model only when this is advertised.
|
|
621
|
+
excomGate: EXCOM_GATE_VERSION,
|
|
616
622
|
// Browser control capability report (plan Section 4).
|
|
617
623
|
browserControl: {
|
|
618
624
|
version: BROWSER_CONTROL_VERSION,
|
|
@@ -1848,6 +1854,21 @@ export async function connect(opts) {
|
|
|
1848
1854
|
}
|
|
1849
1855
|
continue;
|
|
1850
1856
|
}
|
|
1857
|
+
// EXCOM gate frames (excomGate.ts): answered here, never forwarded
|
|
1858
|
+
// as assistant events. A host frame from a spare (no session) is denied.
|
|
1859
|
+
if (parsed?.kind === "excom_gate") {
|
|
1860
|
+
const write = (l) => { proc.stdin?.write(l); };
|
|
1861
|
+
if (session.forward)
|
|
1862
|
+
void relayExcomGate(parsed, { sessionId: session.sid, socket, write });
|
|
1863
|
+
else
|
|
1864
|
+
write(JSON.stringify({ kind: "excom_gate_reply", gateId: String(parsed.gateId || ""), decision: "deny", message: "No executive session on this host, so nothing ran." }) + "\n");
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
if (parsed?.kind === "excom_gate_result") {
|
|
1868
|
+
if (session.forward)
|
|
1869
|
+
relayExcomGateResult(parsed, { sessionId: session.sid, socket });
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1851
1872
|
if (parsed?.kind === "done" && typeof parsed.turnId === "string")
|
|
1852
1873
|
session.turnFiles?.delete(parsed.turnId);
|
|
1853
1874
|
if (typeof parsed?.memoryWatermark === "number")
|
|
@@ -2574,6 +2595,8 @@ export async function connect(opts) {
|
|
|
2574
2595
|
// v4: the turn's output dir (absent: the host keeps its boot-time dir
|
|
2575
2596
|
// and reports no files).
|
|
2576
2597
|
...(turnOutDir ? { output_dir: turnOutDir } : {}),
|
|
2598
|
+
// EXCOM gate: the host asks before every tool and fails closed.
|
|
2599
|
+
...(Number(payload.excomGate) >= 1 ? { excom_gate: 1 } : {}),
|
|
2577
2600
|
}) + "\n");
|
|
2578
2601
|
}
|
|
2579
2602
|
catch (e) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Advertised as `excomGate` in runner:hello (server EXCOM_GATE_VERSION). */
|
|
2
|
+
export declare const EXCOM_GATE_VERSION = 1;
|
|
3
|
+
/** Below the host's own wait (MEL_EXCOM_GATE_TIMEOUT_S, 30 s), so the host gets an explicit deny. */
|
|
4
|
+
export declare const EXCOM_GATE_ACK_MS = 25000;
|
|
5
|
+
export interface GateSocketLike {
|
|
6
|
+
connected?: boolean;
|
|
7
|
+
emit(event: string, payload: unknown, ack?: (reply: unknown) => void): unknown;
|
|
8
|
+
}
|
|
9
|
+
export interface GateRelayCtx {
|
|
10
|
+
sessionId: string;
|
|
11
|
+
socket: GateSocketLike;
|
|
12
|
+
/** Writes one line to the host's stdin. */
|
|
13
|
+
write: (line: string) => void;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export type GateDecision = "allow" | "deny" | "ask";
|
|
17
|
+
export interface GateReplyFrame {
|
|
18
|
+
kind: "excom_gate_reply";
|
|
19
|
+
gateId: string;
|
|
20
|
+
decision: GateDecision;
|
|
21
|
+
message?: string;
|
|
22
|
+
requestId?: string;
|
|
23
|
+
cached?: string;
|
|
24
|
+
report?: "meta" | "content";
|
|
25
|
+
isWrite?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** The reply the host gets: only known fields, any doubt is a deny. */
|
|
28
|
+
export declare function sanitizeGateReply(gateId: string, raw: unknown): GateReplyFrame;
|
|
29
|
+
/** One `excom_gate` frame from the host: ask the server, answer the host once. */
|
|
30
|
+
export declare function relayExcomGate(frame: Record<string, unknown>, ctx: GateRelayCtx): Promise<GateReplyFrame>;
|
|
31
|
+
/** One `excom_gate_result` frame from the host: metadata to the server (best-effort). */
|
|
32
|
+
export declare function relayExcomGateResult(frame: Record<string, unknown>, ctx: Pick<GateRelayCtx, "sessionId" | "socket">): boolean;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// excomGate.ts — the runner half of the EXCOM gate (server:
|
|
2
|
+
// services/excom/excomRunnerGate.ts, host: assistantHost.py "EXCOM gate").
|
|
3
|
+
//
|
|
4
|
+
// An EXCOM executive's turn runs on this computer only when the server can
|
|
5
|
+
// decide every tool call. The host writes `excom_gate` {gateId, tool, args} on
|
|
6
|
+
// stdout and blocks that call; this relay asks the server
|
|
7
|
+
// (`runner:excom_gate`, answered on the ack) and writes the answer back on the
|
|
8
|
+
// host's stdin (`excom_gate_reply`). After an allowed call the host writes
|
|
9
|
+
// `excom_gate_result` (metadata, plus the text of a vault read), relayed as
|
|
10
|
+
// `runner:excom_gate_result`.
|
|
11
|
+
//
|
|
12
|
+
// Fail closed: a malformed frame, a disconnected socket, an error, no answer
|
|
13
|
+
// within EXCOM_GATE_ACK_MS or a malformed answer all become `deny`. Gate
|
|
14
|
+
// frames are never forwarded as assistant events and argument values are
|
|
15
|
+
// never logged.
|
|
16
|
+
/** Advertised as `excomGate` in runner:hello (server EXCOM_GATE_VERSION). */
|
|
17
|
+
export const EXCOM_GATE_VERSION = 1;
|
|
18
|
+
/** Below the host's own wait (MEL_EXCOM_GATE_TIMEOUT_S, 30 s), so the host gets an explicit deny. */
|
|
19
|
+
export const EXCOM_GATE_ACK_MS = 25_000;
|
|
20
|
+
const CONTENT_MAX = 64_000;
|
|
21
|
+
const str = (v, max) => (typeof v === "string" ? v.slice(0, max) : "");
|
|
22
|
+
/** The reply the host gets: only known fields, any doubt is a deny. */
|
|
23
|
+
export function sanitizeGateReply(gateId, raw) {
|
|
24
|
+
const r = (raw && typeof raw === "object" ? raw : {});
|
|
25
|
+
const decision = r.decision;
|
|
26
|
+
if (r.gateId !== gateId || (decision !== "allow" && decision !== "deny" && decision !== "ask")) {
|
|
27
|
+
return { kind: "excom_gate_reply", gateId, decision: "deny", message: "The executive gate answered something unreadable, so nothing ran." };
|
|
28
|
+
}
|
|
29
|
+
const out = { kind: "excom_gate_reply", gateId, decision };
|
|
30
|
+
if (typeof r.message === "string")
|
|
31
|
+
out.message = r.message.slice(0, 4_000);
|
|
32
|
+
if (typeof r.requestId === "string")
|
|
33
|
+
out.requestId = r.requestId.slice(0, 120);
|
|
34
|
+
if (decision === "allow" && typeof r.cached === "string")
|
|
35
|
+
out.cached = r.cached;
|
|
36
|
+
if (r.report === "content" || r.report === "meta")
|
|
37
|
+
out.report = r.report;
|
|
38
|
+
if (typeof r.isWrite === "boolean")
|
|
39
|
+
out.isWrite = r.isWrite;
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
const denyFrame = (gateId, message) => ({ kind: "excom_gate_reply", gateId, decision: "deny", message });
|
|
43
|
+
/** One `excom_gate` frame from the host: ask the server, answer the host once. */
|
|
44
|
+
export function relayExcomGate(frame, ctx) {
|
|
45
|
+
const gateId = str(frame?.gateId, 80);
|
|
46
|
+
const turnId = str(frame?.turnId, 200);
|
|
47
|
+
const tool = str(frame?.tool, 200);
|
|
48
|
+
const args = frame?.args && typeof frame.args === "object" && !Array.isArray(frame.args) ? frame.args : {};
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
let done = false;
|
|
51
|
+
let timer;
|
|
52
|
+
const answer = (reply) => {
|
|
53
|
+
if (done)
|
|
54
|
+
return;
|
|
55
|
+
done = true;
|
|
56
|
+
if (timer)
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
try {
|
|
59
|
+
ctx.write(JSON.stringify(reply) + "\n");
|
|
60
|
+
}
|
|
61
|
+
catch { /* the host is gone: its own wait denies */ }
|
|
62
|
+
resolve(reply);
|
|
63
|
+
};
|
|
64
|
+
if (!gateId || !tool || !turnId) {
|
|
65
|
+
answer(denyFrame(gateId, "The executive gate refused a malformed call. Nothing ran."));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (ctx.socket.connected === false) {
|
|
69
|
+
answer(denyFrame(gateId, "The executive gate is not reachable (the runner is offline), so nothing ran."));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
timer = setTimeout(() => answer(denyFrame(gateId, "The executive gate did not answer in time, so nothing ran.")), ctx.timeoutMs ?? EXCOM_GATE_ACK_MS);
|
|
73
|
+
try {
|
|
74
|
+
ctx.socket.emit("runner:excom_gate", { sessionId: ctx.sessionId, turnId, gateId, tool, args }, (reply) => answer(sanitizeGateReply(gateId, reply)));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
answer(denyFrame(gateId, "The executive gate failed on this call, so nothing ran."));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** One `excom_gate_result` frame from the host: metadata to the server (best-effort). */
|
|
82
|
+
export function relayExcomGateResult(frame, ctx) {
|
|
83
|
+
const gateId = str(frame?.gateId, 80);
|
|
84
|
+
const turnId = str(frame?.turnId, 200);
|
|
85
|
+
if (!gateId || !turnId || ctx.socket.connected === false)
|
|
86
|
+
return false;
|
|
87
|
+
const payload = {
|
|
88
|
+
sessionId: ctx.sessionId, turnId, gateId, ok: frame.ok === true,
|
|
89
|
+
chars: Math.max(0, Math.trunc(Number(frame.chars) || 0)), uncertain: frame.uncertain === true,
|
|
90
|
+
};
|
|
91
|
+
if (typeof frame.content === "string")
|
|
92
|
+
payload.content = frame.content.slice(0, CONTENT_MAX);
|
|
93
|
+
try {
|
|
94
|
+
ctx.socket.emit("runner:excom_gate_result", payload);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|