@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.
- package/dist/connection.js +91 -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,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
|
-
|
|
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
|
+
}
|