@melaya/runner 1.1.53 → 1.1.55

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,16 @@ 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,
881
+ // laya-local (decide_* tools): the runner can't reach the box's private
882
+ // 127.0.0.1:8770, so point LAYA_LOCAL_URL at the server's PER-USER-gated
883
+ // proxy. AFTER the credential spread so it can't be overridden. The proxy
884
+ // authenticates with this run's signed MEL_RUN_TOKEN (already in
885
+ // payload.credentials) - never a shared secret - and meters per user.
886
+ LAYA_LOCAL_URL: opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/$/, "") +
887
+ "/api/v1/internal/laya",
818
888
  // If a Luma browser bridge is up (i.e. the user has signed in
819
889
  // via this runner), inject its localhost URL + bearer token so
820
890
  // shared/tools/luma.py routes /event/register through Playwright
@@ -854,6 +924,18 @@ export async function connect(opts) {
854
924
  ...(browserToken && browserBridge
855
925
  ? { MEL_BROWSER_URL: browserBridge.url, MEL_BROWSER_TOKEN: browserToken }
856
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
+ : {}),
857
939
  };
858
940
  // Surface the tier to the chat panel as a system message so the
859
941
  // operator sees WHY a small model is running with tighter caps.
@@ -1680,6 +1762,12 @@ export async function connect(opts) {
1680
1762
  : {}),
1681
1763
  // Per-user creds (incl. MELAYA_API_KEY + the selected connectors' env).
1682
1764
  ...(payload.credentials || {}),
1765
+ // laya-local (decide_* built-in tools, always-on core): the runner can't
1766
+ // see the box's private 127.0.0.1:8770, so point LAYA_LOCAL_URL at the
1767
+ // server's PER-USER-gated proxy. AFTER creds so it can't be overridden.
1768
+ // The proxy authenticates with this user's MELAYA_API_KEY (in the
1769
+ // projected creds) - never a shared secret - and meters per user.
1770
+ LAYA_LOCAL_URL: httpBase.replace(/\/$/, "") + "/api/v1/internal/laya",
1683
1771
  };
1684
1772
  const session = launchAssistantHost({
1685
1773
  env, pythonPath: envResult.pythonPath, workDir, stagedHost,
@@ -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.53",
3
+ "version": "1.1.55",
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",