@melaya/runner 1.1.61 → 1.1.62
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/assistantFiles.d.ts +29 -0
- package/dist/assistantFiles.js +113 -0
- package/dist/assistantHost.py +76 -1
- package/dist/cli.js +7 -0
- package/dist/connection.js +146 -55
- package/dist/folderPicker.d.ts +15 -0
- package/dist/folderPicker.js +69 -0
- package/dist/localRelay.d.ts +8 -1
- package/dist/localRelay.js +21 -1
- package/dist/localVault.py +745 -0
- package/dist/localVaultMirror.py +507 -0
- package/dist/obsidianApp.d.ts +324 -0
- package/dist/obsidianApp.js +1143 -0
- package/dist/obsidianAppCli.d.ts +15 -0
- package/dist/obsidianAppCli.js +123 -0
- package/dist/vaultMirror.d.ts +104 -0
- package/dist/vaultMirror.js +305 -0
- package/dist/vaults.d.ts +350 -0
- package/dist/vaults.js +1130 -0
- package/localVault.py +745 -0
- package/localVaultMirror.py +507 -0
- package/package.json +4 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export declare const TURN_FILE_MAX_BYTES: number;
|
|
2
|
+
export declare const TURN_FILES_MAX = 5;
|
|
3
|
+
export declare const TURN_FILES_MAX_BYTES: number;
|
|
4
|
+
/** Mirror of the server's RUNNER_FILE_EXTS. */
|
|
5
|
+
export declare const TURN_FILE_EXTS: ReadonlySet<string>;
|
|
6
|
+
export interface TurnFileState {
|
|
7
|
+
dir: string;
|
|
8
|
+
sent: number;
|
|
9
|
+
bytes: number;
|
|
10
|
+
names: Set<string>;
|
|
11
|
+
}
|
|
12
|
+
/** The output dir of one Assistant turn (created). The turn id becomes a safe
|
|
13
|
+
* folder name; `base` is for tests. */
|
|
14
|
+
export declare function turnOutputDir(turnId: string, base?: string): string;
|
|
15
|
+
export declare function newTurnFileState(dir: string): TurnFileState;
|
|
16
|
+
export type TurnFileResult = {
|
|
17
|
+
ok: true;
|
|
18
|
+
file: {
|
|
19
|
+
name: string;
|
|
20
|
+
ext: string;
|
|
21
|
+
size: number;
|
|
22
|
+
b64: string;
|
|
23
|
+
};
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: string;
|
|
27
|
+
};
|
|
28
|
+
/** Read one file the host reported for this turn, under the rules above. */
|
|
29
|
+
export declare function readTurnFile(state: TurnFileState, reportedPath: unknown): TurnFileResult;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Files the runner-hosted Assistant's agent writes during a turn (assistant
|
|
2
|
+
// protocol 4): the runner, not the Python host, decides what leaves this
|
|
3
|
+
// computer.
|
|
4
|
+
//
|
|
5
|
+
// A turn the server marks `fileDelivery` gets its own output dir
|
|
6
|
+
// (<home>/Documents/Melaya-Pipelines/Melaya Assistant/outputs/<turn>), handed to
|
|
7
|
+
// the host as MELAYA_OUTPUT_DIR for that turn: every file-producing tool resolves
|
|
8
|
+
// its path there (shared/runtime/tool_meta.resolve_output_path). When the turn
|
|
9
|
+
// ends the host lists the files it finds there as `file` frames {name, path}.
|
|
10
|
+
// The runner then reads a file only when:
|
|
11
|
+
// - its real path is INSIDE that turn's dir (no `..`, no symlink out, no
|
|
12
|
+
// other turn's dir, no other folder of the computer),
|
|
13
|
+
// - it is a regular file with an allowed extension (documents, sheets, decks,
|
|
14
|
+
// PDFs, text exports, images, audio / video, archives; no HTML, SVG,
|
|
15
|
+
// scripts or executables),
|
|
16
|
+
// - it is at most 25 MB, at most 5 files and 50 MB per turn,
|
|
17
|
+
// and relays {name, ext, size, b64} to the server on the session's own socket.
|
|
18
|
+
// The server re-checks the same caps and stages the bytes for the turn's user
|
|
19
|
+
// (server/src/services/agents/assistantFiles.ts). The files also stay on the
|
|
20
|
+
// computer. A turn without `fileDelivery` (older server) keeps today's
|
|
21
|
+
// behaviour: no per-turn dir, nothing relayed.
|
|
22
|
+
import { closeSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync } from "node:fs";
|
|
23
|
+
import { homedir } from "node:os";
|
|
24
|
+
import { basename, isAbsolute, join, relative, sep } from "node:path";
|
|
25
|
+
export const TURN_FILE_MAX_BYTES = 25 * 1024 * 1024;
|
|
26
|
+
export const TURN_FILES_MAX = 5;
|
|
27
|
+
export const TURN_FILES_MAX_BYTES = 50 * 1024 * 1024;
|
|
28
|
+
/** Mirror of the server's RUNNER_FILE_EXTS. */
|
|
29
|
+
export const TURN_FILE_EXTS = new Set([
|
|
30
|
+
"docx", "doc", "odt", "rtf", "xlsx", "xls", "ods", "csv", "tsv", "pptx", "ppt", "odp", "pdf",
|
|
31
|
+
"txt", "md", "json", "ics", "png", "jpg", "jpeg", "gif", "webp", "mp3", "wav", "mp4", "zip",
|
|
32
|
+
]);
|
|
33
|
+
/** The output dir of one Assistant turn (created). The turn id becomes a safe
|
|
34
|
+
* folder name; `base` is for tests. */
|
|
35
|
+
export function turnOutputDir(turnId, base = join(homedir(), "Documents", "Melaya-Pipelines", "Melaya Assistant", "outputs")) {
|
|
36
|
+
const safe = String(turnId || "").replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.{2,}/g, "_").replace(/^\.+/, "").slice(0, 80) || `turn-${Date.now()}`;
|
|
37
|
+
const dir = join(base, safe);
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
export function newTurnFileState(dir) {
|
|
42
|
+
return { dir, sent: 0, bytes: 0, names: new Set() };
|
|
43
|
+
}
|
|
44
|
+
const inside = (root, p) => {
|
|
45
|
+
const rel = relative(root, p);
|
|
46
|
+
return !!rel && !rel.startsWith("..") && !isAbsolute(rel) && rel.split(sep)[0] !== "..";
|
|
47
|
+
};
|
|
48
|
+
/** Read one file the host reported for this turn, under the rules above. */
|
|
49
|
+
export function readTurnFile(state, reportedPath) {
|
|
50
|
+
if (state.sent >= TURN_FILES_MAX)
|
|
51
|
+
return { ok: false, reason: "too_many_files" };
|
|
52
|
+
const raw = typeof reportedPath === "string" ? reportedPath : "";
|
|
53
|
+
if (!raw || raw.length > 4096 || raw.includes("\u0000"))
|
|
54
|
+
return { ok: false, reason: "bad_path" };
|
|
55
|
+
let root;
|
|
56
|
+
let real;
|
|
57
|
+
try {
|
|
58
|
+
root = realpathSync(state.dir);
|
|
59
|
+
const candidate = isAbsolute(raw) ? raw : join(state.dir, raw);
|
|
60
|
+
// A symlink is refused outright (even one pointing inside the dir).
|
|
61
|
+
if (lstatSync(candidate).isSymbolicLink())
|
|
62
|
+
return { ok: false, reason: "symlink" };
|
|
63
|
+
real = realpathSync(candidate);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { ok: false, reason: "not_found" };
|
|
67
|
+
}
|
|
68
|
+
if (!inside(root, real))
|
|
69
|
+
return { ok: false, reason: "outside_turn_dir" };
|
|
70
|
+
const name = basename(real);
|
|
71
|
+
const dot = name.lastIndexOf(".");
|
|
72
|
+
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
73
|
+
if (!ext || !TURN_FILE_EXTS.has(ext))
|
|
74
|
+
return { ok: false, reason: "extension_not_allowed" };
|
|
75
|
+
if (state.names.has(real))
|
|
76
|
+
return { ok: false, reason: "duplicate" };
|
|
77
|
+
let fd = -1;
|
|
78
|
+
try {
|
|
79
|
+
fd = openSync(real, "r");
|
|
80
|
+
const st = fstatSync(fd);
|
|
81
|
+
if (!st.isFile())
|
|
82
|
+
return { ok: false, reason: "not_a_file" };
|
|
83
|
+
if (st.size <= 0)
|
|
84
|
+
return { ok: false, reason: "empty" };
|
|
85
|
+
if (st.size > TURN_FILE_MAX_BYTES)
|
|
86
|
+
return { ok: false, reason: "too_large" };
|
|
87
|
+
if (state.bytes + st.size > TURN_FILES_MAX_BYTES)
|
|
88
|
+
return { ok: false, reason: "turn_too_large" };
|
|
89
|
+
const buf = Buffer.alloc(st.size);
|
|
90
|
+
let off = 0;
|
|
91
|
+
while (off < st.size) {
|
|
92
|
+
const n = readSync(fd, buf, off, st.size - off, off);
|
|
93
|
+
if (n <= 0)
|
|
94
|
+
break;
|
|
95
|
+
off += n;
|
|
96
|
+
}
|
|
97
|
+
state.sent += 1;
|
|
98
|
+
state.bytes += off;
|
|
99
|
+
state.names.add(real);
|
|
100
|
+
return { ok: true, file: { name, ext, size: off, b64: buf.subarray(0, off).toString("base64") } };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { ok: false, reason: "unreadable" };
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
if (fd >= 0) {
|
|
107
|
+
try {
|
|
108
|
+
closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
catch { /* already closed */ }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
package/dist/assistantHost.py
CHANGED
|
@@ -399,7 +399,10 @@ _RESERVED_ENV = frozenset({
|
|
|
399
399
|
"PYTHONPATH", "PYTHONHOME", "PYTHONIOENCODING", "PYTHONUTF8", "SSL_CERT_FILE",
|
|
400
400
|
"REQUESTS_CA_BUNDLE", "LAYA_LOCAL_URL", "LMSTUDIO_BASE_URL", "OLLAMA_BASE_URL",
|
|
401
401
|
})
|
|
402
|
-
|
|
402
|
+
# Obsidian vaults: the turn's capability, the local relay URL and the selection
|
|
403
|
+
# (for the prompt only), installed and purged per turn like any connector env.
|
|
404
|
+
_ALLOWED_MEL_ENV = frozenset({"MEL_LUMA_BROWSER_URL", "MEL_LUMA_BROWSER_TOKEN",
|
|
405
|
+
"MEL_VAULT_CAP", "MEL_VAULT_API_URL", "MEL_VAULT_SELECTION"})
|
|
403
406
|
_LIVE = {
|
|
404
407
|
"lock": threading.RLock(),
|
|
405
408
|
"toolkit": None, # the lazy toolkit (set_lazy_connectors target)
|
|
@@ -675,6 +678,71 @@ def _emit(turn_id: str, kind: str, **fields) -> None:
|
|
|
675
678
|
pass
|
|
676
679
|
|
|
677
680
|
|
|
681
|
+
# ── Output files (assistant protocol 4) ──────────────────────────────────────
|
|
682
|
+
# A turn frame may carry `output_dir`: a fresh folder the runner made for THIS
|
|
683
|
+
# turn. It becomes MELAYA_OUTPUT_DIR for the turn, so every file-producing tool
|
|
684
|
+
# (word / excel / pptx / pdf / chart / image tools, via
|
|
685
|
+
# shared.runtime.tool_meta.resolve_output_path) writes there, and the previous
|
|
686
|
+
# value is restored after the turn. Before `done` the host lists the regular
|
|
687
|
+
# files it finds there as `file` frames {name, path}. It never reads or sends the
|
|
688
|
+
# bytes: the runner (packages/runner/src/assistantFiles.ts) checks the path is
|
|
689
|
+
# inside that dir, applies the extension and size caps and relays the file. No
|
|
690
|
+
# `output_dir` (older server or runner): nothing changes.
|
|
691
|
+
_TURN_FILES = {"dir": "", "prev": None}
|
|
692
|
+
_TURN_FILES_LIST_MAX = 20
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _apply_turn_output_dir(req) -> None:
|
|
696
|
+
d = req.get("output_dir") if isinstance(req, dict) else None
|
|
697
|
+
if not (isinstance(d, str) and d.strip() and os.path.isdir(d)):
|
|
698
|
+
_TURN_FILES["dir"] = ""
|
|
699
|
+
return
|
|
700
|
+
_TURN_FILES["dir"] = d
|
|
701
|
+
_TURN_FILES["prev"] = os.environ.get("MELAYA_OUTPUT_DIR")
|
|
702
|
+
os.environ["MELAYA_OUTPUT_DIR"] = d
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _clear_turn_output_dir() -> None:
|
|
706
|
+
if not _TURN_FILES["dir"]:
|
|
707
|
+
return
|
|
708
|
+
prev = _TURN_FILES["prev"]
|
|
709
|
+
if prev is None:
|
|
710
|
+
os.environ.pop("MELAYA_OUTPUT_DIR", None)
|
|
711
|
+
else:
|
|
712
|
+
os.environ["MELAYA_OUTPUT_DIR"] = prev
|
|
713
|
+
_TURN_FILES["dir"] = ""
|
|
714
|
+
_TURN_FILES["prev"] = None
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _emit_turn_files(turn_id: str) -> int:
|
|
718
|
+
"""`file` frames for the files in this turn's output dir (oldest first)."""
|
|
719
|
+
root = _TURN_FILES["dir"]
|
|
720
|
+
if not root:
|
|
721
|
+
return 0
|
|
722
|
+
found: list = []
|
|
723
|
+
try:
|
|
724
|
+
for dirpath, dirs, files in os.walk(root, followlinks=False):
|
|
725
|
+
dirs[:] = [x for x in dirs if not x.startswith(".")]
|
|
726
|
+
for name in files:
|
|
727
|
+
p = os.path.join(dirpath, name)
|
|
728
|
+
if name.startswith(".") or os.path.islink(p) or not os.path.isfile(p):
|
|
729
|
+
continue
|
|
730
|
+
try:
|
|
731
|
+
found.append((os.path.getmtime(p), p, name))
|
|
732
|
+
except OSError:
|
|
733
|
+
continue
|
|
734
|
+
if len(found) > 200:
|
|
735
|
+
break
|
|
736
|
+
except Exception:
|
|
737
|
+
return 0
|
|
738
|
+
found.sort()
|
|
739
|
+
n = 0
|
|
740
|
+
for _mt, p, name in found[:_TURN_FILES_LIST_MAX]:
|
|
741
|
+
_emit(turn_id, "file", name=name, path=p)
|
|
742
|
+
n += 1
|
|
743
|
+
return n
|
|
744
|
+
|
|
745
|
+
|
|
678
746
|
def _split_stream_blocks(content):
|
|
679
747
|
"""Split a streamed message's content into (answer_text, thinking_text,
|
|
680
748
|
[(tool_id, tool_name)]).
|
|
@@ -1480,6 +1548,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
1480
1548
|
# that named neither what broke nor where. Reported 2026-09-03: two turns
|
|
1481
1549
|
# in a row with no cause anywhere on screen.
|
|
1482
1550
|
_emit(turn_id, "error", message=_turn_error_message(exc))
|
|
1551
|
+
_emit_turn_files(turn_id) # v4: before done, so the stream route stages them
|
|
1483
1552
|
_emit(turn_id, "done")
|
|
1484
1553
|
return
|
|
1485
1554
|
|
|
@@ -1487,6 +1556,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
1487
1556
|
_stream["turnId"] = ""
|
|
1488
1557
|
_log("turn cancelled by user STOP")
|
|
1489
1558
|
_emit(turn_id, "text", content="Stopped.")
|
|
1559
|
+
_emit_turn_files(turn_id) # v4: before done, so the stream route stages them
|
|
1490
1560
|
_emit(turn_id, "done")
|
|
1491
1561
|
return
|
|
1492
1562
|
|
|
@@ -1497,6 +1567,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
1497
1567
|
"This browser task ran past its time budget and was stopped. "
|
|
1498
1568
|
"Ask again with a narrower step, or split it into two."
|
|
1499
1569
|
))
|
|
1570
|
+
_emit_turn_files(turn_id) # v4: before done, so the stream route stages them
|
|
1500
1571
|
_emit(turn_id, "done")
|
|
1501
1572
|
return
|
|
1502
1573
|
|
|
@@ -1510,6 +1581,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, ima
|
|
|
1510
1581
|
# once a browser-driving turn finishes cleanly.
|
|
1511
1582
|
if _stream.get("usedBrowser"):
|
|
1512
1583
|
_emit(turn_id, "browser_done", ok=True)
|
|
1584
|
+
_emit_turn_files(turn_id) # v4: before done, so the stream route stages them
|
|
1513
1585
|
_emit(turn_id, "done")
|
|
1514
1586
|
|
|
1515
1587
|
|
|
@@ -1730,9 +1802,12 @@ def main() -> int:
|
|
|
1730
1802
|
# ALWAYS purge it — success, error, or cancel — so no reusable grant
|
|
1731
1803
|
# ever survives in the warm host environment.
|
|
1732
1804
|
browser_turn = _apply_browser_turn_grant(req)
|
|
1805
|
+
# v4: this turn's output dir (absent: unchanged behaviour).
|
|
1806
|
+
_apply_turn_output_dir(req)
|
|
1733
1807
|
try:
|
|
1734
1808
|
_run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
|
|
1735
1809
|
finally:
|
|
1810
|
+
_clear_turn_output_dir()
|
|
1736
1811
|
_purge_browser_turn_grant()
|
|
1737
1812
|
# Connector credentials live for the turn only; the selection (ids,
|
|
1738
1813
|
# no secret) stays and the next frame re-sends the env.
|
package/dist/cli.js
CHANGED
|
@@ -189,6 +189,13 @@ async function bootstrap() {
|
|
|
189
189
|
await copilotLogin();
|
|
190
190
|
return;
|
|
191
191
|
}
|
|
192
|
+
// `melaya-runner obsidian-rest` — connect a vault's Obsidian Local REST API
|
|
193
|
+
// by hand; the key is typed here and never leaves this computer.
|
|
194
|
+
if (process.argv[2] === "obsidian-rest") {
|
|
195
|
+
const { obsidianRestCli } = await import("./obsidianAppCli.js");
|
|
196
|
+
await obsidianRestCli();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
192
199
|
await main();
|
|
193
200
|
}
|
|
194
201
|
bootstrap().catch(async (e) => {
|
package/dist/connection.js
CHANGED
|
@@ -17,7 +17,7 @@ import { spawn } from "child_process";
|
|
|
17
17
|
import { writeFileSync, mkdirSync, existsSync, readFileSync, copyFileSync } from "fs";
|
|
18
18
|
import { dirname, join } from "path";
|
|
19
19
|
import { fileURLToPath } from "url";
|
|
20
|
-
import { tmpdir, homedir } from "os";
|
|
20
|
+
import { tmpdir, homedir, hostname } from "os";
|
|
21
21
|
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
|
22
22
|
import { startLocalRelay, setActiveProject } from "./localRelay.js";
|
|
23
23
|
// ESM equivalent of __dirname — required for the rag:ingest handler to
|
|
@@ -34,6 +34,15 @@ import { verifyBrowserGrant, createInMemoryReplayStore, loadPinnedPublicKeysFrom
|
|
|
34
34
|
import { discoverEngines } from "./browserProvisioner.js";
|
|
35
35
|
import { getRunnerDeviceId } from "./sessionManager.js";
|
|
36
36
|
import { scrubTriggeredEnv } from "./triggerEnv.js";
|
|
37
|
+
import { pickNativeFolder } from "./folderPicker.js";
|
|
38
|
+
import { newTurnFileState, readTurnFile, turnOutputDir } from "./assistantFiles.js";
|
|
39
|
+
// Obsidian vault host (docs/plans/obsidian-vault-protocol.md): device vaults
|
|
40
|
+
// registered on this computer, served after the runner verifies each
|
|
41
|
+
// capability itself.
|
|
42
|
+
import { VAULT_HOST_CAPABILITY, VaultHostProcess, VaultRegistry, VaultService, attachVaultSocketHandlers, findVaultHostScript, setVaultRelayPort, vaultToolEnv, trustRunVaultCapability, } from "./vaults.js";
|
|
43
|
+
// Obsidian app bridge: App ops through the user's running Obsidian (CLI /
|
|
44
|
+
// Local REST API); the REST key stays in ~/.melaya-runner/vaults.
|
|
45
|
+
import { ObsidianAppBridge } from "./obsidianApp.js";
|
|
37
46
|
// Same ceiling the builder enforces on RunRequest.trigger (48 KB UTF-8 of the
|
|
38
47
|
// compact JSON) and the server dispatch route re-checks (Buffer.byteLength).
|
|
39
48
|
const MAX_TRIGGER_JSON_BYTES = 48 * 1024;
|
|
@@ -61,7 +70,12 @@ const RESERVED_BROWSER_ENV = ["MEL_BROWSER_URL", "MEL_BROWSER_TOKEN"];
|
|
|
61
70
|
// the autonomy mode never reboots the host, and a warm spare stays adoptable
|
|
62
71
|
// whatever the selection. A v2 server keeps sending connectors at boot; this
|
|
63
72
|
// runner then behaves exactly as before (turnConnectors absent).
|
|
64
|
-
|
|
73
|
+
// v4: output files. A turn frame with `fileDelivery` gets a per-turn output dir
|
|
74
|
+
// (MELAYA_OUTPUT_DIR for that turn); the files the agent wrote there are relayed
|
|
75
|
+
// to the server as `file` frames with their bytes, under the caps and path
|
|
76
|
+
// confinement of assistantFiles.ts. A server that does not send `fileDelivery`
|
|
77
|
+
// gets exactly the v3 behaviour (files stay on this computer only).
|
|
78
|
+
const ASSISTANT_PROTOCOL_VERSION = 4;
|
|
65
79
|
// Best-effort read of our own package version (mirrors cli.ts). Advertised in the
|
|
66
80
|
// hello so the server can log / gate on a package floor too.
|
|
67
81
|
let RUNNER_VERSION = "0.0.0";
|
|
@@ -150,6 +164,63 @@ export async function connect(opts) {
|
|
|
150
164
|
// is refused (plan Section 4 binding rule).
|
|
151
165
|
let socketGeneration = 0;
|
|
152
166
|
const runnerDeviceId = getRunnerDeviceId();
|
|
167
|
+
// ── Obsidian vault host ───────────────────────────────────────────────
|
|
168
|
+
// Registrations persist in ~/.melaya-runner/vaults/registry.json; the
|
|
169
|
+
// Python host (localVault.py, engine from the shared bundle) starts lazily
|
|
170
|
+
// on the first register/op, or now when vaults are already registered.
|
|
171
|
+
const vaultRegistry = new VaultRegistry();
|
|
172
|
+
vaultRegistry.load();
|
|
173
|
+
const vaultLog = (m) => { if (opts.verbose)
|
|
174
|
+
console.log(chalk.gray(` [vaults] ${m}`)); };
|
|
175
|
+
const vaultHost = new VaultHostProcess({
|
|
176
|
+
wanted: () => vaultRegistry.list().length > 0,
|
|
177
|
+
log: vaultLog,
|
|
178
|
+
launch: async () => {
|
|
179
|
+
const { ensurePythonEnv, getCertBundlePath } = await import("./pythonEnv.js");
|
|
180
|
+
const { getLocalSharedVersion } = await import("./sharedVendor.js");
|
|
181
|
+
let localVersion = getLocalSharedVersion();
|
|
182
|
+
if (!localVersion) {
|
|
183
|
+
await ensureSharedModules(opts.serverUrl, "latest", opts.token);
|
|
184
|
+
localVersion = getLocalSharedVersion();
|
|
185
|
+
}
|
|
186
|
+
if (!localVersion)
|
|
187
|
+
throw new Error("runner python runtime still setting up");
|
|
188
|
+
const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => vaultLog(`venv: ${m}`));
|
|
189
|
+
if (!env.ok)
|
|
190
|
+
throw new Error(`venv bootstrap failed: ${env.reason}`);
|
|
191
|
+
const script = findVaultHostScript(__dirname);
|
|
192
|
+
if (!script)
|
|
193
|
+
throw new Error("localVault.py not found — update @melaya/runner");
|
|
194
|
+
mkdirSync(vaultRegistry.dir, { recursive: true, mode: 0o700 });
|
|
195
|
+
const certBundle = getCertBundlePath();
|
|
196
|
+
return {
|
|
197
|
+
pythonPath: env.pythonPath,
|
|
198
|
+
scriptPath: script,
|
|
199
|
+
cwd: vaultRegistry.dir,
|
|
200
|
+
env: {
|
|
201
|
+
...process.env,
|
|
202
|
+
PYTHONPATH: getSharedDir(),
|
|
203
|
+
PYTHONIOENCODING: "utf-8",
|
|
204
|
+
PYTHONUTF8: "1",
|
|
205
|
+
...(certBundle ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {}),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
const vaultService = new VaultService({
|
|
211
|
+
rnrId: runnerDeviceId,
|
|
212
|
+
registry: vaultRegistry,
|
|
213
|
+
host: vaultHost,
|
|
214
|
+
serverHttpBase: opts.serverUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"),
|
|
215
|
+
emitStatus: (ev) => { if (socket.connected)
|
|
216
|
+
socket.emit("runner:vault-status", ev); },
|
|
217
|
+
log: vaultLog,
|
|
218
|
+
app: new ObsidianAppBridge({ dir: vaultRegistry.dir, log: vaultLog }),
|
|
219
|
+
});
|
|
220
|
+
attachVaultSocketHandlers(socket, vaultService, () => pickNativeFolder("Select your Obsidian vault folder"));
|
|
221
|
+
if (vaultRegistry.list().length) {
|
|
222
|
+
void vaultService.restore().catch((e) => vaultLog(`restore failed: ${e?.message || e}`));
|
|
223
|
+
}
|
|
153
224
|
const _ensureBrowserBridge = async () => {
|
|
154
225
|
if (browserBridge)
|
|
155
226
|
return browserBridge;
|
|
@@ -346,7 +417,9 @@ export async function connect(opts) {
|
|
|
346
417
|
// event_triggers_v1: this runner writes payload.triggerJson to
|
|
347
418
|
// <runDir>/trigger.json + MEL_TRIGGER_FILE. The server reports
|
|
348
419
|
// triggerAccepted from it; older runners silently drop the block.
|
|
349
|
-
|
|
420
|
+
// vault_host_v1: this runner hosts Obsidian vaults on this computer
|
|
421
|
+
// (runner:vault-* events); the server then pushes runner:vaultKeys.
|
|
422
|
+
capabilities: ["assistant_session", `browser_control_v${BROWSER_CONTROL_VERSION}`, EVENT_TRIGGERS_CAPABILITY, VAULT_HOST_CAPABILITY],
|
|
350
423
|
package: "@melaya/runner",
|
|
351
424
|
version: RUNNER_VERSION,
|
|
352
425
|
assistantProtocol: ASSISTANT_PROTOCOL_VERSION,
|
|
@@ -358,10 +431,22 @@ export async function connect(opts) {
|
|
|
358
431
|
cdp: true,
|
|
359
432
|
engines: browserEngines,
|
|
360
433
|
},
|
|
434
|
+
// Device vaults bind to this stable install id (protocol §3).
|
|
435
|
+
vaultHost: {
|
|
436
|
+
runnerDevice: runnerDeviceId,
|
|
437
|
+
deviceName: hostname(),
|
|
438
|
+
platform: process.platform,
|
|
439
|
+
},
|
|
361
440
|
});
|
|
441
|
+
// Index statuses produced while disconnected (or before this socket).
|
|
442
|
+
for (const ev of vaultService.latestStatuses())
|
|
443
|
+
socket.emit("runner:vault-status", ev);
|
|
362
444
|
// Start local event relay
|
|
363
445
|
if (!relay) {
|
|
364
|
-
relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl
|
|
446
|
+
relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl, {
|
|
447
|
+
vaultOp: (req, res) => vaultService.handleRelayHttp(req, res),
|
|
448
|
+
});
|
|
449
|
+
setVaultRelayPort(relay.port);
|
|
365
450
|
if (opts.verbose) {
|
|
366
451
|
console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
|
|
367
452
|
}
|
|
@@ -998,6 +1083,19 @@ export async function connect(opts) {
|
|
|
998
1083
|
...(isTriggeredRun
|
|
999
1084
|
? { MEL_HITL_APPROVAL_TTL_S: String(triggerApprovalTtl) }
|
|
1000
1085
|
: {}),
|
|
1086
|
+
// Obsidian vaults, reserved LAST: the capability the server issued for
|
|
1087
|
+
// this run on this computer, served by the local relay (a vault on this
|
|
1088
|
+
// computer is read in place, any other through Melaya). A credential
|
|
1089
|
+
// can never replace these names.
|
|
1090
|
+
MEL_VAULT_CAP: "",
|
|
1091
|
+
MEL_VAULT_API_URL: "",
|
|
1092
|
+
...(() => {
|
|
1093
|
+
const cap = typeof payload.vaultCapability === "string"
|
|
1094
|
+
? String(payload.vaultCapability)
|
|
1095
|
+
: "";
|
|
1096
|
+
trustRunVaultCapability(cap);
|
|
1097
|
+
return vaultToolEnv(cap);
|
|
1098
|
+
})(),
|
|
1001
1099
|
};
|
|
1002
1100
|
// Surface the tier to the chat panel as a system message so the
|
|
1003
1101
|
// operator sees WHY a small model is running with tighter caps.
|
|
@@ -1513,6 +1611,23 @@ export async function connect(opts) {
|
|
|
1513
1611
|
console.log(chalk.gray(` [assistant-stdout] ${t.slice(0, mi)}`));
|
|
1514
1612
|
try {
|
|
1515
1613
|
const parsed = JSON.parse(t.slice(mi + _EVENT_PREFIX.length));
|
|
1614
|
+
// v4 output file: the host only names a path; the runner reads it
|
|
1615
|
+
// (confined to the turn's dir, capped) and relays the bytes. A frame
|
|
1616
|
+
// for a turn without fileDelivery, or a refused file, is dropped:
|
|
1617
|
+
// the raw path never leaves this computer.
|
|
1618
|
+
if (parsed?.kind === "file") {
|
|
1619
|
+
const st = session.turnFiles?.get(String(parsed.turnId || ""));
|
|
1620
|
+
if (st && session.forward) {
|
|
1621
|
+
const r = readTurnFile(st, parsed.path);
|
|
1622
|
+
if (r.ok)
|
|
1623
|
+
socket.emit("runner:assistant_event", { sessionId: session.sid, turnId: String(parsed.turnId), kind: "file", ...r.file });
|
|
1624
|
+
else if (opts.verbose)
|
|
1625
|
+
console.log(chalk.gray(` [assistant] output file not relayed (${r.reason})`));
|
|
1626
|
+
}
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
if (parsed?.kind === "done" && typeof parsed.turnId === "string")
|
|
1630
|
+
session.turnFiles?.delete(parsed.turnId);
|
|
1516
1631
|
if (typeof parsed?.memoryWatermark === "number")
|
|
1517
1632
|
session.memoryWatermark = parsed.memoryWatermark;
|
|
1518
1633
|
if (parsed?.kind === "ready") {
|
|
@@ -2125,12 +2240,32 @@ export async function connect(opts) {
|
|
|
2125
2240
|
if (env && typeof env === "object")
|
|
2126
2241
|
turnConnectorEnv[svc] = { ...env };
|
|
2127
2242
|
}
|
|
2243
|
+
// Obsidian vaults: the server sends only the capability; the local relay
|
|
2244
|
+
// serves vaults on this computer and forwards the rest to Melaya.
|
|
2245
|
+
if (turnConnectorEnv.obsidian?.MEL_VAULT_CAP) {
|
|
2246
|
+
trustRunVaultCapability(turnConnectorEnv.obsidian.MEL_VAULT_CAP);
|
|
2247
|
+
turnConnectorEnv.obsidian = { ...turnConnectorEnv.obsidian, ...vaultToolEnv(turnConnectorEnv.obsidian.MEL_VAULT_CAP) };
|
|
2248
|
+
}
|
|
2128
2249
|
if (turnConnectors.includes("luma")) {
|
|
2129
2250
|
await _ensureLumaBridge().catch(() => { });
|
|
2130
2251
|
if (lumaBridge)
|
|
2131
2252
|
turnConnectorEnv.luma = { ...(turnConnectorEnv.luma || {}), MEL_LUMA_BROWSER_URL: lumaBridge.url, MEL_LUMA_BROWSER_TOKEN: lumaBridge.token };
|
|
2132
2253
|
}
|
|
2133
2254
|
}
|
|
2255
|
+
// v4 output files: a fresh dir for this turn (MELAYA_OUTPUT_DIR in the host
|
|
2256
|
+
// for the turn); its files are relayed at turn end (stdout `file` frames).
|
|
2257
|
+
let turnOutDir = "";
|
|
2258
|
+
if (payload.fileDelivery === true && payload.turnId) {
|
|
2259
|
+
try {
|
|
2260
|
+
turnOutDir = turnOutputDir(String(payload.turnId));
|
|
2261
|
+
(s.turnFiles ??= new Map()).set(String(payload.turnId), newTurnFileState(turnOutDir));
|
|
2262
|
+
}
|
|
2263
|
+
catch (e) {
|
|
2264
|
+
turnOutDir = "";
|
|
2265
|
+
if (opts.verbose)
|
|
2266
|
+
console.log(chalk.gray(` [assistant] no output dir for this turn: ${e?.message || e}`));
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2134
2269
|
// Capture for the stdout "done" handler closure below.
|
|
2135
2270
|
const _turnBridgeRunId = turnBridgeRunId;
|
|
2136
2271
|
const _turnId = payload.turnId;
|
|
@@ -2194,6 +2329,9 @@ export async function connect(opts) {
|
|
|
2194
2329
|
// v3 per-turn connectors (absent from a v2 server: the host keeps its
|
|
2195
2330
|
// boot-time connectors).
|
|
2196
2331
|
...(turnConnectors ? { connectors: turnConnectors, connector_env: turnConnectorEnv } : {}),
|
|
2332
|
+
// v4: the turn's output dir (absent: the host keeps its boot-time dir
|
|
2333
|
+
// and reports no files).
|
|
2334
|
+
...(turnOutDir ? { output_dir: turnOutDir } : {}),
|
|
2197
2335
|
}) + "\n");
|
|
2198
2336
|
}
|
|
2199
2337
|
catch (e) {
|
|
@@ -2299,60 +2437,13 @@ export async function connect(opts) {
|
|
|
2299
2437
|
socket.emit("runner:pick-folder-result", { session_id: sid, ok, path, error });
|
|
2300
2438
|
};
|
|
2301
2439
|
try {
|
|
2302
|
-
const
|
|
2303
|
-
|
|
2304
|
-
const run = (cmd, args) => new Promise((resolve, reject) => {
|
|
2305
|
-
execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
|
|
2306
|
-
if (err) {
|
|
2307
|
-
const out = (stdout || "").trim();
|
|
2308
|
-
const errMsg = (stderr || err.message || "").trim();
|
|
2309
|
-
// osascript returns code 1 when user cancels — empty stdout
|
|
2310
|
-
if (!out && /User canceled|cancelled/i.test(errMsg))
|
|
2311
|
-
return resolve("");
|
|
2312
|
-
return reject(new Error(errMsg || "pick failed"));
|
|
2313
|
-
}
|
|
2314
|
-
resolve((stdout || "").trim());
|
|
2315
|
-
});
|
|
2316
|
-
});
|
|
2317
|
-
let picked = "";
|
|
2318
|
-
if (platform === "darwin") {
|
|
2319
|
-
// osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
|
|
2320
|
-
const raw = await run("osascript", [
|
|
2321
|
-
"-e", 'tell application "System Events"',
|
|
2322
|
-
"-e", "activate",
|
|
2323
|
-
"-e", "set p to choose folder with prompt \"Select your reference docs folder\"",
|
|
2324
|
-
"-e", "end tell",
|
|
2325
|
-
"-e", "return POSIX path of p",
|
|
2326
|
-
]);
|
|
2327
|
-
picked = raw.replace(/\/$/, "");
|
|
2328
|
-
}
|
|
2329
|
-
else if (platform === "win32") {
|
|
2330
|
-
const ps = [
|
|
2331
|
-
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
2332
|
-
"$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
|
|
2333
|
-
"$f.Description = 'Select your reference docs folder';",
|
|
2334
|
-
"$f.ShowNewFolderButton = $true;",
|
|
2335
|
-
"if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
|
|
2336
|
-
].join(" ");
|
|
2337
|
-
picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
|
|
2338
|
-
}
|
|
2339
|
-
else {
|
|
2340
|
-
// Linux — try zenity, fall back to error if missing
|
|
2341
|
-
picked = await run("zenity", [
|
|
2342
|
-
"--file-selection", "--directory",
|
|
2343
|
-
"--title=Select your reference docs folder",
|
|
2344
|
-
]);
|
|
2345
|
-
}
|
|
2346
|
-
if (!picked)
|
|
2440
|
+
const r = await pickNativeFolder("Select your reference docs folder");
|
|
2441
|
+
if (!("path" in r))
|
|
2347
2442
|
return reply(false, null, "cancelled");
|
|
2348
|
-
reply(true,
|
|
2443
|
+
reply(true, r.path);
|
|
2349
2444
|
}
|
|
2350
2445
|
catch (e) {
|
|
2351
|
-
|
|
2352
|
-
const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
|
|
2353
|
-
? "Install 'zenity' (apt install zenity) or type the path manually."
|
|
2354
|
-
: "";
|
|
2355
|
-
reply(false, null, hint ? `${msg} — ${hint}` : msg);
|
|
2446
|
+
reply(false, null, String(e?.message || "pick failed"));
|
|
2356
2447
|
}
|
|
2357
2448
|
});
|
|
2358
2449
|
socket.on("rag:ingest", async (payload) => {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native OS folder dialog, shared by runner:pick-folder (static-context / RAG
|
|
3
|
+
* folders) and runner:vault-pick-folder (Obsidian vaults).
|
|
4
|
+
*
|
|
5
|
+
* Browsers can't reveal absolute paths for security, so the runner — which
|
|
6
|
+
* lives on the user's box — pops the dialog. Mac: osascript. Windows:
|
|
7
|
+
* PowerShell WinForms. Linux: zenity. Fixed argv; the prompt is a constant
|
|
8
|
+
* chosen by the caller, never user or model input.
|
|
9
|
+
*/
|
|
10
|
+
export type FolderPickResult = {
|
|
11
|
+
path: string;
|
|
12
|
+
} | {
|
|
13
|
+
cancelled: true;
|
|
14
|
+
};
|
|
15
|
+
export declare function pickNativeFolder(prompt: string): Promise<FolderPickResult>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native OS folder dialog, shared by runner:pick-folder (static-context / RAG
|
|
3
|
+
* folders) and runner:vault-pick-folder (Obsidian vaults).
|
|
4
|
+
*
|
|
5
|
+
* Browsers can't reveal absolute paths for security, so the runner — which
|
|
6
|
+
* lives on the user's box — pops the dialog. Mac: osascript. Windows:
|
|
7
|
+
* PowerShell WinForms. Linux: zenity. Fixed argv; the prompt is a constant
|
|
8
|
+
* chosen by the caller, never user or model input.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from "child_process";
|
|
11
|
+
export async function pickNativeFolder(prompt) {
|
|
12
|
+
const platform = process.platform;
|
|
13
|
+
const run = (cmd, args) => new Promise((resolve, reject) => {
|
|
14
|
+
execFile(cmd, args, { maxBuffer: 1024 * 64 }, (err, stdout, stderr) => {
|
|
15
|
+
if (err) {
|
|
16
|
+
const out = (stdout || "").trim();
|
|
17
|
+
const errMsg = (stderr || err.message || "").trim();
|
|
18
|
+
// osascript returns code 1 when user cancels — empty stdout
|
|
19
|
+
if (!out && /User canceled|cancelled/i.test(errMsg))
|
|
20
|
+
return resolve("");
|
|
21
|
+
return reject(new Error(errMsg || "pick failed"));
|
|
22
|
+
}
|
|
23
|
+
resolve((stdout || "").trim());
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
// Quotes would break out of the AppleScript / PowerShell string literal.
|
|
27
|
+
const safePrompt = prompt.replace(/["'`$\\]/g, "");
|
|
28
|
+
let picked = "";
|
|
29
|
+
try {
|
|
30
|
+
if (platform === "darwin") {
|
|
31
|
+
// osascript returns "Macintosh HD:Users:me:Documents" — convert to POSIX
|
|
32
|
+
const raw = await run("osascript", [
|
|
33
|
+
"-e", 'tell application "System Events"',
|
|
34
|
+
"-e", "activate",
|
|
35
|
+
"-e", `set p to choose folder with prompt "${safePrompt}"`,
|
|
36
|
+
"-e", "end tell",
|
|
37
|
+
"-e", "return POSIX path of p",
|
|
38
|
+
]);
|
|
39
|
+
picked = raw.replace(/\/$/, "");
|
|
40
|
+
}
|
|
41
|
+
else if (platform === "win32") {
|
|
42
|
+
const ps = [
|
|
43
|
+
"Add-Type -AssemblyName System.Windows.Forms;",
|
|
44
|
+
"$f = New-Object System.Windows.Forms.FolderBrowserDialog;",
|
|
45
|
+
`$f.Description = '${safePrompt}';`,
|
|
46
|
+
"$f.ShowNewFolderButton = $true;",
|
|
47
|
+
"if ($f.ShowDialog() -eq 'OK') { [Console]::Out.Write($f.SelectedPath) }",
|
|
48
|
+
].join(" ");
|
|
49
|
+
picked = await run("powershell", ["-NoProfile", "-STA", "-Command", ps]);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
// Linux — try zenity, fall back to error if missing
|
|
53
|
+
picked = await run("zenity", [
|
|
54
|
+
"--file-selection", "--directory",
|
|
55
|
+
`--title=${safePrompt}`,
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
const msg = String(e?.message || "pick failed");
|
|
61
|
+
const hint = /ENOENT|not found|spawn/i.test(msg) && process.platform === "linux"
|
|
62
|
+
? "Install 'zenity' (apt install zenity) or type the path manually."
|
|
63
|
+
: "";
|
|
64
|
+
throw new Error(hint ? `${msg} — ${hint}` : msg);
|
|
65
|
+
}
|
|
66
|
+
if (!picked)
|
|
67
|
+
return { cancelled: true };
|
|
68
|
+
return { path: picked };
|
|
69
|
+
}
|