@melaya/runner 1.1.54 → 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.
- package/dist/connection.js +78 -3
- package/dist/triggerEnv.d.ts +3 -0
- package/dist/triggerEnv.js +37 -0
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -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
|
+
}
|