@melaya/runner 1.1.80 → 1.1.82

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.
@@ -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
- if "hitl_mode" in req:
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,207 @@ 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
+ # Python connector tools answer an error as plain text ("No Obsidian vault is
854
+ # selected", "Google is not authorized. Reconnect ...", "Vault error (...)",
855
+ # "[timeout] ..."): the gate must report those as failed, never as done
856
+ # (mirrors server/src/services/agents/toolStatus.ts).
857
+ _EXCOM_FAIL_PREFIXES = ("error", "could not", "couldn't", "failed", "functionnotfounderror", "tool error",
858
+ "vault error", "[timeout]", "[error]", "http error", "cannot ", "unable to",
859
+ "no obsidian vault", "traceback (most recent call last)")
860
+ _EXCOM_AUTH_GAP_RE = None
861
+ _EXCOM_HTTP_ERR_RE = None
862
+
863
+
864
+ def _excom_looks_failed(text: str) -> bool:
865
+ global _EXCOM_AUTH_GAP_RE, _EXCOM_HTTP_ERR_RE
866
+ import re as _re
867
+ if _EXCOM_AUTH_GAP_RE is None:
868
+ _EXCOM_AUTH_GAP_RE = _re.compile(
869
+ r"\b(is|are) not (authori[sz]ed|connected)\b|\bnot authori[sz]ed\b|\bauthori[sz]ation (failed|expired|required)\b"
870
+ r"|\b(access|refresh) token (is )?(missing|expired|revoked)\b|\bno (access|refresh) token\b"
871
+ r"|\bmissing credentials?\b|\breconnect (the|your|it)\b")
872
+ _EXCOM_HTTP_ERR_RE = _re.compile(r"^http [45]\d\d\b")
873
+ raw = (text or "").lstrip().lower()
874
+ head = raw[:160]
875
+ if head.startswith(_EXCOM_FAIL_PREFIXES) or _EXCOM_HTTP_ERR_RE.match(head):
876
+ return True
877
+ # A structured payload (JSON) that merely mentions it is not an error.
878
+ return len(raw) <= 600 and not raw.startswith(("{", "[")) and bool(_EXCOM_AUTH_GAP_RE.search(head))
879
+
880
+
881
+ async def _excom_gate_middleware(kwargs, next_handler):
882
+ """agentscope Toolkit middleware: the gate around every tool call."""
883
+ if not _EXCOM_GATE.get("on"):
884
+ async for chunk in next_handler(**kwargs):
885
+ yield chunk
886
+ return
887
+ import asyncio
888
+ tc = kwargs.get("tool_call") or {}
889
+ name = str((tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "")) or "")
890
+ args = (tc.get("input") if isinstance(tc, dict) else getattr(tc, "input", None)) or {}
891
+ if _EXCOM_GATE.get("deferred"):
892
+ yield _excom_text_response("Not run: this turn is waiting for a human approval and ends now.")
893
+ return
894
+ reply = await asyncio.to_thread(_excom_gate_ask, name, args)
895
+ decision = reply.get("decision")
896
+ if decision == "ask":
897
+ yield _excom_text_response(str(reply.get("message") or json.dumps({"status": "approval_pending", "requestId": reply.get("requestId"), "tool": name})))
898
+ # The turn ends after this round, like the cloud loop (the job parks).
899
+ _EXCOM_GATE["deferred"] = {"requestId": str(reply.get("requestId") or ""), "tool": name}
900
+ return
901
+ if decision != "allow":
902
+ yield _excom_text_response(str(reply.get("message") or "Outside this executive's autonomy. Nothing ran."))
903
+ return
904
+ if isinstance(reply.get("cached"), str):
905
+ yield _excom_text_response(reply["cached"])
906
+ return
907
+ gid = str(reply.get("gateId") or "")
908
+ is_write = reply.get("isWrite") is True
909
+ last = None
910
+ try:
911
+ async for chunk in next_handler(**kwargs):
912
+ last = chunk
913
+ yield chunk
914
+ except BaseException:
915
+ # Cancelled, timed out or raised: a write may have happened.
916
+ _excom_gate_report(gid, False, "", is_write, False)
917
+ raise
918
+ text = _excom_response_text(last)
919
+ failed = _excom_looks_failed(text)
920
+ _excom_gate_report(gid, not failed, text, failed and is_write and "timeout" in text.lower(), reply.get("report") == "content")
921
+
922
+
923
+ def _install_excom_gate(toolkit) -> bool:
924
+ try:
925
+ if toolkit is None or not hasattr(toolkit, "register_middleware"):
926
+ return False
927
+ toolkit.register_middleware(_excom_gate_middleware)
928
+ return True
929
+ except Exception as exc:
930
+ _log(f"excom gate middleware unavailable: {type(exc).__name__}")
931
+ return False
932
+
933
+
934
+ def _excom_gate_begin(turn_id: str) -> None:
935
+ _EXCOM_GATE["on"] = True
936
+ _EXCOM_GATE["turnId"] = turn_id
937
+ _EXCOM_GATE["deferred"] = None
938
+ # The server decides every call: the host's own Safe-mode card stays out
939
+ # (lazy_registry reads the mode at call time).
940
+ _apply_hitl_mode("autonomous")
941
+
942
+
943
+ def _excom_gate_end() -> None:
944
+ _EXCOM_GATE["on"] = False
945
+ _EXCOM_GATE["turnId"] = ""
946
+ _EXCOM_GATE["deferred"] = None
947
+ _excom_gate_release_all()
948
+
949
+
738
950
  # ── Output files (assistant protocol 4) ──────────────────────────────────────
739
951
  # A turn frame may carry `output_dir`: a fresh folder the runner made for THIS
740
952
  # turn. It becomes MELAYA_OUTPUT_DIR for the turn, so every file-producing tool
@@ -1096,6 +1308,11 @@ def _build_agent():
1096
1308
  except Exception:
1097
1309
  toolkit = build_toolkit(names=[])
1098
1310
 
1311
+ # EXCOM gate: the server decides every tool of an executive's turn. The
1312
+ # middleware is a no-op on every other turn; without it an EXCOM turn is
1313
+ # refused (fail closed, see the turn loop in main()).
1314
+ _EXCOM_GATE["installed"] = _install_excom_gate(toolkit)
1315
+
1099
1316
  phone_rule = (
1100
1317
  "- If the user asks you to DO something on their phone (open an app, browse, "
1101
1318
  "tap, type, comment, post), use the phone_* tools. ALWAYS call "
@@ -1704,6 +1921,15 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1704
1921
  except BaseException: # CancelledError + any teardown error
1705
1922
  pass
1706
1923
  return _CANCELLED
1924
+ # EXCOM: a call went to a human approval; the turn ends after
1925
+ # this round (the job parks), like the cloud loop.
1926
+ if _EXCOM_GATE.get("on") and _EXCOM_GATE.get("deferred"):
1927
+ task.cancel()
1928
+ try:
1929
+ await task
1930
+ except BaseException:
1931
+ pass
1932
+ return _DEFERRED
1707
1933
  if wallclock_s and time.time() - started_at > wallclock_s:
1708
1934
  task.cancel()
1709
1935
  try:
@@ -1742,6 +1968,16 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
1742
1968
  _emit(turn_id, "done")
1743
1969
  return
1744
1970
 
1971
+ if result is _DEFERRED:
1972
+ _flush_delta(turn_id)
1973
+ _stream["turnId"] = ""
1974
+ pend = _EXCOM_GATE.get("deferred") or {}
1975
+ _log("turn ends on an EXCOM approval request")
1976
+ _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.")
1977
+ _emit_turn_files(turn_id)
1978
+ _emit(turn_id, "done")
1979
+ return
1980
+
1745
1981
  if result is _WALLCLOCK:
1746
1982
  _stream["turnId"] = ""
1747
1983
  _log("browser turn wall-clock budget exceeded")
@@ -1789,6 +2025,11 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
1789
2025
  if not tid or tid == _stream.get("turnId"):
1790
2026
  _stream["cancel"] = True
1791
2027
  continue # consumed — do not queue as a turn
2028
+ # EXCOM gate answer: wakes the tool call waiting on it, WHILE
2029
+ # the turn runs. Never queued as a turn.
2030
+ if isinstance(req, dict) and req.get("kind") == "excom_gate_reply":
2031
+ _excom_gate_deliver(req)
2032
+ continue
1792
2033
  # Live mid-turn update (autonomy mode / connectors): applied HERE,
1793
2034
  # while the turn runs, like STOP. Never queued as a turn.
1794
2035
  if isinstance(req, dict) and req.get("kind") == "turn_update":
@@ -1801,6 +2042,7 @@ def _stdin_reader(q: "queue.Queue[str]") -> None:
1801
2042
  except Exception:
1802
2043
  pass
1803
2044
  finally:
2045
+ _excom_gate_release_all() # no answer can come any more: deny (fail closed)
1804
2046
  q.put("") # sentinel: stdin closed
1805
2047
 
1806
2048
 
@@ -1958,6 +2200,20 @@ def main() -> int:
1958
2200
  # it here (before _run_turn) is enough — no agent rebuild needed.
1959
2201
  if "hitl_mode" in req:
1960
2202
  _apply_hitl_mode(req.get("hitl_mode"))
2203
+ # EXCOM gate: the server decides every tool of this turn. A host that
2204
+ # cannot install the gate refuses the turn (fail closed).
2205
+ excom_gate = bool(req.get("excom_gate"))
2206
+ if excom_gate and not _EXCOM_GATE.get("installed"):
2207
+ _log("excom gate requested but unavailable: turn refused")
2208
+ _emit(turn_id, "error", code="runner_constraint_unsupported", message=(
2209
+ "This runner cannot enforce the executive's autonomy gate, so nothing ran. "
2210
+ "Update the runner, or pick a cloud model for this executive."))
2211
+ _emit(turn_id, "done")
2212
+ continue
2213
+ if excom_gate:
2214
+ _excom_gate_begin(turn_id)
2215
+ else:
2216
+ _EXCOM_GATE["on"] = False
1961
2217
  # Product usage attribution is per turn. This must be applied before the
1962
2218
  # AgentScope spans are created; _emit_usage force-flushes them before the
1963
2219
  # next turn can replace the value.
@@ -1974,6 +2230,8 @@ def main() -> int:
1974
2230
  _apply_static_context(agent, base_sys_prompt, req.get("static_context"))
1975
2231
  if not message and not images:
1976
2232
  _purge_connector_env()
2233
+ if excom_gate:
2234
+ _excom_gate_end()
1977
2235
  _emit(turn_id, "done")
1978
2236
  continue
1979
2237
  # P2-6: nudge the live memory budget down if a prior turn's OOM downgraded
@@ -1989,6 +2247,8 @@ def main() -> int:
1989
2247
  try:
1990
2248
  _run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
1991
2249
  finally:
2250
+ if excom_gate:
2251
+ _excom_gate_end()
1992
2252
  _clear_turn_output_dir()
1993
2253
  _purge_browser_turn_grant()
1994
2254
  # Connector credentials live for the turn only; the selection (ids,
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.80",
3
+ "version": "1.1.82",
4
4
  "description": "Run Melaya AI pipelines locally with your own LM Studio or Ollama models",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Melaya Labs LLC",