@melaya/runner 1.1.54 → 1.1.56

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.
@@ -32,6 +32,13 @@ import { startBrowserBridge } from "./browserBridge.js";
32
32
  import { verifyBrowserGrant, createInMemoryReplayStore, loadPinnedPublicKeysFromEnv, BrowserGrantError, } from "./browserGrantVerify.js";
33
33
  import { discoverEngines } from "./browserProvisioner.js";
34
34
  import { getRunnerDeviceId } from "./sessionManager.js";
35
+ import { scrubTriggeredEnv } from "./triggerEnv.js";
36
+ // Same ceiling the builder enforces on RunRequest.trigger (48 KB UTF-8 of the
37
+ // compact JSON) and the server dispatch route re-checks (Buffer.byteLength).
38
+ const MAX_TRIGGER_JSON_BYTES = 48 * 1024;
39
+ // Advertised in runner:hello; must match server runnerNamespace
40
+ // EVENT_TRIGGERS_CAPABILITY.
41
+ const EVENT_TRIGGERS_CAPABILITY = "event_triggers_v1";
35
42
  const HEARTBEAT_INTERVAL = 30_000;
36
43
  const activeProcesses = new Map();
37
44
  // Melaya Browser: the versioned capability advertised in the runner hello
@@ -271,7 +278,10 @@ export async function connect(opts) {
271
278
  // to runners new enough to host them (older runners never get one → the
272
279
  // server falls back with a clear "update your runner" instead of hanging).
273
280
  socket.emit("runner:hello", {
274
- capabilities: ["assistant_session", `browser_control_v${BROWSER_CONTROL_VERSION}`],
281
+ // event_triggers_v1: this runner writes payload.triggerJson to
282
+ // <runDir>/trigger.json + MEL_TRIGGER_FILE. The server reports
283
+ // triggerAccepted from it; older runners silently drop the block.
284
+ capabilities: ["assistant_session", `browser_control_v${BROWSER_CONTROL_VERSION}`, EVENT_TRIGGERS_CAPABILITY],
275
285
  package: "@melaya/runner",
276
286
  version: RUNNER_VERSION,
277
287
  assistantProtocol: ASSISTANT_PROTOCOL_VERSION,
@@ -631,12 +641,51 @@ export async function connect(opts) {
631
641
  runId: payload.runId,
632
642
  project: payload.project,
633
643
  pipelineName: payload.pipelineName,
644
+ ...(typeof payload.openedFrom === "string" && /^[a-z][a-z_]{0,15}$/.test(payload.openedFrom)
645
+ ? { openedFrom: payload.openedFrom }
646
+ : {}),
634
647
  });
635
648
  // Write main.py to temp dir
636
649
  const runDir = join(tmpdir(), `melaya-run-${payload.runId}`);
637
650
  mkdirSync(runDir, { recursive: true });
638
651
  writeFileSync(join(runDir, "main.py"), payload.mainPyContent, "utf-8");
639
652
  writeFileSync(join(runDir, "config.json"), payload.configJson, "utf-8");
653
+ // Event-trigger payload → <runDir>/trigger.json (MEL_TRIGGER_FILE below).
654
+ // Size-capped + must parse as a JSON object; anything else is dropped
655
+ // (the run still starts, just without the trigger block).
656
+ let triggerFile = "";
657
+ // ST-24: a run is TRIGGERED as soon as the dispatch carries triggerJson
658
+ // at all (even an oversize / corrupt one that is dropped below): the env
659
+ // scrub keys on this, never on whether trigger.json got written.
660
+ const isTriggeredRun = payload.triggerJson !== undefined && payload.triggerJson !== null;
661
+ // EXT-17 / J8: the trigger may opt in to keep the egress/residential
662
+ // proxy for this run (never the account key). Only a well-formed block
663
+ // can opt in; a corrupt one keeps the strict default.
664
+ let triggerAllowsProxy = false;
665
+ // ST-15 / J9: in-run HITL TTL of the triggered run (seconds).
666
+ let triggerApprovalTtl = 3600;
667
+ if (typeof payload.triggerJson === "string" && payload.triggerJson) {
668
+ try {
669
+ if (Buffer.byteLength(payload.triggerJson, "utf-8") > MAX_TRIGGER_JSON_BYTES) {
670
+ throw new Error("trigger payload exceeds 48 KB");
671
+ }
672
+ const parsed = JSON.parse(payload.triggerJson);
673
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
674
+ throw new Error("trigger payload is not a JSON object");
675
+ }
676
+ triggerAllowsProxy = parsed.allow_egress_proxy === true;
677
+ if (Number.isInteger(parsed.approval_ttl_s)) {
678
+ triggerApprovalTtl = Math.max(60, Math.min(86_400, parsed.approval_ttl_s));
679
+ }
680
+ triggerFile = join(runDir, "trigger.json");
681
+ // 0600: attacker-influenceable payload, readable by the run only.
682
+ writeFileSync(triggerFile, payload.triggerJson, { encoding: "utf-8", mode: 0o600 });
683
+ }
684
+ catch (e) {
685
+ triggerFile = "";
686
+ console.log(chalk.yellow(` ! trigger payload ignored for run ${payload.runId}: ${e.message}`));
687
+ }
688
+ }
640
689
  // Write rag/ static-context files alongside main.py so
641
690
  // _load_rag_context() inside the spawned pipeline sees them.
642
691
  // Two paths:
@@ -756,6 +805,19 @@ export async function connect(opts) {
756
805
  if (k.startsWith("MEL_MODEL_"))
757
806
  delete inherited[k];
758
807
  }
808
+ // Event triggers: MEL_TRIGGER_FILE is runner-owned (set below). For a
809
+ // TRIGGERED run also drop the operator's shell-exported full-account key
810
+ // and proxy credentials (brief section 7) - the payload is untrusted.
811
+ delete inherited.MEL_TRIGGER_FILE;
812
+ delete inherited.MEL_HITL_APPROVAL_TTL_S;
813
+ // Triggered-run credential bundle: the account key never reaches the run
814
+ // in ANY alias (ST-03: MELAYA_API_KEY, PLATFORM_API_<digest>, or any other
815
+ // key carrying the same value), and no proxy creds unless opted in (J8).
816
+ // The builder already strips these; this is the runner-side backstop.
817
+ const runCredentials = { ...(payload.credentials || {}) };
818
+ if (isTriggeredRun) {
819
+ scrubTriggeredEnv(inherited, runCredentials, triggerAllowsProxy);
820
+ }
759
821
  // Reserved browser bridge env names are runner-owned and injected
760
822
  // LAST below (plan Section 4). Scrub any operator-exported value so
761
823
  // a stale/hostile MEL_BROWSER_* in the shell can't shadow ours.
@@ -813,8 +875,9 @@ export async function connect(opts) {
813
875
  }
814
876
  return dir;
815
877
  })(),
816
- // Inject per-pipeline credentials (already scoped by the server)
817
- ...payload.credentials,
878
+ // Inject per-pipeline credentials (already scoped by the server;
879
+ // scrubbed above for a triggered run)
880
+ ...runCredentials,
818
881
  // laya-local (decide_* tools): the runner can't reach the box's private
819
882
  // 127.0.0.1:8770, so point LAYA_LOCAL_URL at the server's PER-USER-gated
820
883
  // proxy. AFTER the credential spread so it can't be overridden. The proxy
@@ -861,6 +924,18 @@ export async function connect(opts) {
861
924
  ...(browserToken && browserBridge
862
925
  ? { MEL_BROWSER_URL: browserBridge.url, MEL_BROWSER_TOKEN: browserToken }
863
926
  : {}),
927
+ // Event trigger file (reserved: after the credential spread so a
928
+ // credential can never point it elsewhere). Empty string = no trigger.
929
+ MEL_TRIGGER_FILE: triggerFile,
930
+ // Triggered run: forced-safe HITL + expiring in-run approvals (ST-15),
931
+ // also reserved (after the credential spread).
932
+ ...(isTriggeredRun
933
+ ? {
934
+ MEL_HITL_MODE: "safe",
935
+ MEL_HITL_FORCE_SAFE: "1",
936
+ MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl),
937
+ }
938
+ : {}),
864
939
  };
865
940
  // Surface the tier to the chat panel as a system message so the
866
941
  // operator sees WHY a small model is running with tighter caps.
package/dist/pythonEnv.js CHANGED
@@ -97,9 +97,15 @@ const PIP_DEPS = [
97
97
  "python-frontmatter",
98
98
  "aiofiles",
99
99
  // ── runner tools (shared/tools/*.py) ──
100
- "aiohttp",
100
+ "aiohttp>=3.14", // google-genai 2.4+ streams with readline(max_line_length=)
101
101
  "requests",
102
102
  "python-dotenv",
103
+ // google-genai — the native Gemini SDK agentscope's GeminiChatModel imports
104
+ // for provider google/gemini. Missing = every Gemini agent in a runner
105
+ // pipeline died at construction ("cannot import name 'genai' from
106
+ // 'google'"). The 2.4+ SDK streams with aiohttp readline(max_line_length=),
107
+ // which needs aiohttp>=3.14 (same pair as the cloud image lock).
108
+ "google-genai",
103
109
  // Tool-side optional deps that get imported via agentscope.rag /
104
110
  // shared.tools.knowledge (PDF/DOCX/PPTX/XLSX readers, Qdrant local
105
111
  // store, image reader). Most are slim wheels so the cost is low and
@@ -0,0 +1,3 @@
1
+ export declare const TRIGGER_PROXY_ENV: readonly ["EGRESS_PROXY_URL", "MEL_RESIDENTIAL_PROXY_URL"];
2
+ /** Mutates both maps in place. */
3
+ export declare function scrubTriggeredEnv(inherited: Record<string, string | undefined>, credentials: Record<string, string | undefined>, allowEgressProxy: boolean): void;
@@ -0,0 +1,37 @@
1
+ // Env hardening for TRIGGERED pipeline runs (Obsidian 37 Event Triggers).
2
+ //
3
+ // A run started by an event trigger processes an attacker-influenceable
4
+ // payload, so an injected tool call (bash `env`, an http tool) must not be able
5
+ // to read the account key or the residential proxy credentials:
6
+ // * the account key in ANY alias: MELAYA_API_KEY, PLATFORM_API_<digest> (the
7
+ // mangled credential-row copy, audit ST-03), or any other variable whose
8
+ // value equals one of those;
9
+ // * EGRESS_PROXY_URL / MEL_RESIDENTIAL_PROXY_URL unless the trigger opted in
10
+ // with allow_egress_proxy (EXT-17 / J8).
11
+ // Applied to BOTH the runner's inherited shell env and the server-shipped
12
+ // credential bundle (the builder strips the bundle too; this is the backstop).
13
+ export const TRIGGER_PROXY_ENV = ["EGRESS_PROXY_URL", "MEL_RESIDENTIAL_PROXY_URL"];
14
+ function isAccountKeyName(k) {
15
+ return k === "MELAYA_API_KEY" || k.startsWith("PLATFORM_API_");
16
+ }
17
+ /** Mutates both maps in place. */
18
+ export function scrubTriggeredEnv(inherited, credentials, allowEgressProxy) {
19
+ const keyValues = new Set();
20
+ for (const src of [inherited, credentials]) {
21
+ for (const [k, v] of Object.entries(src)) {
22
+ if (isAccountKeyName(k) && typeof v === "string" && v.length >= 8)
23
+ keyValues.add(v);
24
+ }
25
+ }
26
+ for (const src of [inherited, credentials]) {
27
+ for (const k of Object.keys(src)) {
28
+ const v = src[k];
29
+ if (isAccountKeyName(k))
30
+ delete src[k];
31
+ else if (!allowEgressProxy && TRIGGER_PROXY_ENV.includes(k))
32
+ delete src[k];
33
+ else if (typeof v === "string" && keyValues.has(v))
34
+ delete src[k];
35
+ }
36
+ }
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.54",
3
+ "version": "1.1.56",
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",