@melaya/runner 1.1.60 → 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 +333 -37
- package/dist/cli.js +7 -0
- package/dist/connection.js +236 -65
- 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/sharedVendor.js +33 -1
- 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
package/dist/vaults.js
ADDED
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obsidian vault host — the runner side of docs/plans/obsidian-vault-protocol.md.
|
|
3
|
+
*
|
|
4
|
+
* A "device vault" is a folder on THIS computer, registered by the server for
|
|
5
|
+
* this runner's stable install id (rnr_…). The runner:
|
|
6
|
+
* - keeps the registrations in ~/.melaya-runner/vaults/registry.json (0600)
|
|
7
|
+
* and re-opens them on start (incremental re-index);
|
|
8
|
+
* - indexes and serves them through a long-lived Python host (localVault.py,
|
|
9
|
+
* the pure-Python engine from the shared bundle), index outside the vault
|
|
10
|
+
* at ~/.melaya-runner/vaults/<vault_id>/index.sqlite;
|
|
11
|
+
* - verifies every vault capability ITSELF (Ed25519, keys pushed with
|
|
12
|
+
* runner:vaultKeys): signature, exp, aud, grant selection, read/write,
|
|
13
|
+
* route policy, pushed revocations, folder bounds. The server relay is
|
|
14
|
+
* not trusted to bypass any of it;
|
|
15
|
+
* - answers server requests on the socket (runner:vault-*) and local agent
|
|
16
|
+
* requests on the local relay (POST /vaults/op), forwarding every vault it
|
|
17
|
+
* does not hold to {server}/api/v1/vault-api/op.
|
|
18
|
+
*
|
|
19
|
+
* Never logged: capability tokens, note content, op arguments.
|
|
20
|
+
*/
|
|
21
|
+
import { createPublicKey, verify as cryptoVerify, randomBytes } from "crypto";
|
|
22
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, watch as fsWatch, writeFileSync, } from "fs";
|
|
23
|
+
import { spawn } from "child_process";
|
|
24
|
+
import { homedir, platform as osPlatform } from "os";
|
|
25
|
+
import path from "path";
|
|
26
|
+
import { Readable } from "stream";
|
|
27
|
+
import { APP_DEV_OPS, APP_ONLY_OPS, APP_WRITE_OPS } from "./obsidianApp.js";
|
|
28
|
+
import { VaultMirrorManager, mirrorApiBase } from "./vaultMirror.js";
|
|
29
|
+
// ── Wire contract ─────────────────────────────────────────────────────────
|
|
30
|
+
export const VAULT_HOST_CAPABILITY = "vault_host_v1";
|
|
31
|
+
/** Engine methods that change the vault. Everything else is a read. A write
|
|
32
|
+
* needs "write" in the grant's ops; an unknown name stays a read here and the
|
|
33
|
+
* Python host still refuses names that are not public engine methods. The
|
|
34
|
+
* host applies the engine's own proposal in the same request (localVault.py
|
|
35
|
+
* cmd_op), so the list must match its WRITE_OPS. */
|
|
36
|
+
export const VAULT_WRITE_OPS = new Set([
|
|
37
|
+
"create", "append", "prepend", "patch", "move", "rename", "copy", "delete",
|
|
38
|
+
"property_set", "property_remove", "task", "daily_append", "daily_prepend",
|
|
39
|
+
"unique", "template_insert", "bookmark", "canvas_write", "base_create",
|
|
40
|
+
"upload",
|
|
41
|
+
]);
|
|
42
|
+
/** Engine methods no caller may name: `apply` / `resume_recovery` take a
|
|
43
|
+
* caller-supplied proposal (forged bytes), the rest are host lifecycle. */
|
|
44
|
+
export const VAULT_DENIED_OPS = new Set([
|
|
45
|
+
"apply", "resume_recovery", "refresh", "close", "capabilities_list",
|
|
46
|
+
]);
|
|
47
|
+
export function vaultErr(code, message) {
|
|
48
|
+
return { ok: false, error: { code, message, retry_after: null } };
|
|
49
|
+
}
|
|
50
|
+
const VAULT_ID_RE = /^vlt_[A-Za-z0-9]{1,64}$/;
|
|
51
|
+
const OP_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
52
|
+
// A capability lives at most 10 minutes (protocol §1); a token claiming more
|
|
53
|
+
// was not minted by our issuer. Small leeway for runner clock drift.
|
|
54
|
+
const MAX_CAP_LIFETIME_S = 600;
|
|
55
|
+
const CLOCK_SKEW_S = 30;
|
|
56
|
+
const MAX_RELAY_BODY = 8 * 1024 * 1024;
|
|
57
|
+
// ── Capability verification (protocol §1, §2 enforcement step 1) ─────────
|
|
58
|
+
const SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
59
|
+
/** kid → Ed25519 public key, from runner:vaultKeys {keys:[{kid, publicKeyRawB64}]}. */
|
|
60
|
+
export function parseVaultKeys(keys) {
|
|
61
|
+
const out = new Map();
|
|
62
|
+
if (!Array.isArray(keys))
|
|
63
|
+
return out;
|
|
64
|
+
for (const k of keys) {
|
|
65
|
+
const kid = typeof k?.kid === "string" ? k.kid : "";
|
|
66
|
+
const b64 = typeof k?.publicKeyRawB64 === "string" ? k.publicKeyRawB64 : "";
|
|
67
|
+
if (!kid || !b64)
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
const raw = Buffer.from(b64, "base64");
|
|
71
|
+
if (raw.length !== 32)
|
|
72
|
+
continue;
|
|
73
|
+
out.set(kid, createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw]), format: "der", type: "spki" }));
|
|
74
|
+
}
|
|
75
|
+
catch { /* skip a bad key, keep the others */ }
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** Decode the payload WITHOUT verifying it. Only for routing a local relay
|
|
80
|
+
* request (local vs forward); every served request is fully verified. */
|
|
81
|
+
export function decodeVaultCapUnverified(token) {
|
|
82
|
+
const parts = String(token || "").split(".");
|
|
83
|
+
if (parts.length !== 3 || parts[0] !== "v1")
|
|
84
|
+
return null;
|
|
85
|
+
try {
|
|
86
|
+
const p = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
87
|
+
return p && typeof p === "object" && Array.isArray(p.grants) ? p : null;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Run and turn capabilities live as long as the run (up to 12 h, server
|
|
94
|
+
// CAP_MAX_TTL_SECONDS). The server hands them to this runner over the
|
|
95
|
+
// authenticated socket (run dispatch payload, per-turn connector env); only
|
|
96
|
+
// those exact tokens may exceed MAX_CAP_LIFETIME_S, and only on the local relay.
|
|
97
|
+
// Signature, expiry, audience and revocations are still checked on every op.
|
|
98
|
+
const RUN_CAP_MAX_LIFETIME_S = 12 * 3600;
|
|
99
|
+
const _trustedRunCaps = new Map();
|
|
100
|
+
/** Record a run or turn capability received from the server for a local agent. */
|
|
101
|
+
export function trustRunVaultCapability(token) {
|
|
102
|
+
if (!token)
|
|
103
|
+
return;
|
|
104
|
+
const p = decodeVaultCapUnverified(token);
|
|
105
|
+
const exp = Number(p?.exp);
|
|
106
|
+
if (!Number.isFinite(exp))
|
|
107
|
+
return;
|
|
108
|
+
const now = Math.floor(Date.now() / 1000);
|
|
109
|
+
for (const [t, e] of _trustedRunCaps)
|
|
110
|
+
if (e + CLOCK_SKEW_S < now)
|
|
111
|
+
_trustedRunCaps.delete(t);
|
|
112
|
+
_trustedRunCaps.set(token, exp);
|
|
113
|
+
}
|
|
114
|
+
export function isTrustedRunVaultCapability(token) {
|
|
115
|
+
return _trustedRunCaps.has(token);
|
|
116
|
+
}
|
|
117
|
+
/** Signature, version, kid, lifetime and audience. `audiences` is any-of. `maxLifetimeS` is raised only for
|
|
118
|
+
* run/turn capabilities the server handed to this runner (see above). */
|
|
119
|
+
export function verifyVaultCapability(token, keys, audiences, nowSec = Math.floor(Date.now() / 1000), maxLifetimeS = MAX_CAP_LIFETIME_S) {
|
|
120
|
+
const parts = String(token || "").split(".");
|
|
121
|
+
if (parts.length !== 3 || parts[0] !== "v1")
|
|
122
|
+
return { ok: false, code: "forbidden", reason: "malformed" };
|
|
123
|
+
let payload;
|
|
124
|
+
try {
|
|
125
|
+
payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return { ok: false, code: "forbidden", reason: "malformed" };
|
|
129
|
+
}
|
|
130
|
+
if (!payload || typeof payload !== "object" || payload.v !== 1)
|
|
131
|
+
return { ok: false, code: "forbidden", reason: "malformed" };
|
|
132
|
+
const key = keys.get(String(payload.kid || ""));
|
|
133
|
+
if (!key)
|
|
134
|
+
return { ok: false, code: "forbidden", reason: keys.size ? "kid_unknown" : "keys_missing" };
|
|
135
|
+
let sigOk = false;
|
|
136
|
+
try {
|
|
137
|
+
sigOk = cryptoVerify(null, Buffer.from(`v1.${parts[1]}`), key, Buffer.from(parts[2], "base64url"));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
sigOk = false;
|
|
141
|
+
}
|
|
142
|
+
if (!sigOk)
|
|
143
|
+
return { ok: false, code: "forbidden", reason: "bad_signature" };
|
|
144
|
+
const exp = Number(payload.exp);
|
|
145
|
+
const iat = Number(payload.iat);
|
|
146
|
+
if (!Number.isFinite(exp) || exp + CLOCK_SKEW_S < nowSec)
|
|
147
|
+
return { ok: false, code: "forbidden", reason: "expired" };
|
|
148
|
+
if (!Number.isFinite(iat) || iat - CLOCK_SKEW_S > nowSec || exp - iat > maxLifetimeS + CLOCK_SKEW_S) {
|
|
149
|
+
return { ok: false, code: "forbidden", reason: "lifetime_invalid" };
|
|
150
|
+
}
|
|
151
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [];
|
|
152
|
+
if (!audiences.some((a) => aud.includes(a)))
|
|
153
|
+
return { ok: false, code: "forbidden", reason: "aud_mismatch" };
|
|
154
|
+
if (!Array.isArray(payload.grants))
|
|
155
|
+
return { ok: false, code: "forbidden", reason: "malformed" };
|
|
156
|
+
return { ok: true, payload };
|
|
157
|
+
}
|
|
158
|
+
/** `vault` = a vlt_ id in grants, or the exact case-insensitive label of exactly
|
|
159
|
+
* one grant. Empty = the grant marked default, or the only grant. */
|
|
160
|
+
export function selectVaultGrant(payload, vault) {
|
|
161
|
+
const grants = Array.isArray(payload.grants) ? payload.grants : [];
|
|
162
|
+
const want = String(vault ?? "").trim();
|
|
163
|
+
if (!want) {
|
|
164
|
+
const defaults = grants.filter((g) => g.default === true);
|
|
165
|
+
if (defaults.length === 1)
|
|
166
|
+
return { ok: true, grant: defaults[0] };
|
|
167
|
+
if (grants.length === 1)
|
|
168
|
+
return { ok: true, grant: grants[0] };
|
|
169
|
+
if (!grants.length)
|
|
170
|
+
return { ok: false, error: vaultErr("not_selected", "No vault is selected for this run.") };
|
|
171
|
+
return { ok: false, error: vaultErr("ambiguous", "Several vaults are selected; pass vault= with one of their labels.") };
|
|
172
|
+
}
|
|
173
|
+
const byId = grants.find((g) => g.vault_id === want);
|
|
174
|
+
if (byId)
|
|
175
|
+
return { ok: true, grant: byId };
|
|
176
|
+
const lower = want.toLowerCase();
|
|
177
|
+
const byLabel = grants.filter((g) => typeof g.label === "string" && g.label.toLowerCase() === lower);
|
|
178
|
+
if (byLabel.length === 1)
|
|
179
|
+
return { ok: true, grant: byLabel[0] };
|
|
180
|
+
if (byLabel.length > 1)
|
|
181
|
+
return { ok: false, error: vaultErr("ambiguous", "Several selected vaults share that label; pass the vault id.") };
|
|
182
|
+
return { ok: false, error: vaultErr("not_selected", "That vault is not selected for this run.") };
|
|
183
|
+
}
|
|
184
|
+
export function isVaultWriteOp(op) {
|
|
185
|
+
return VAULT_WRITE_OPS.has(op) || APP_WRITE_OPS.has(op);
|
|
186
|
+
}
|
|
187
|
+
// ── Revocations (runner:vaultRevoke) ──────────────────────────────────────
|
|
188
|
+
/** vaultId+grantId → the minimum revocation_version still valid. A grant whose
|
|
189
|
+
* capability carries an older version fails the next call, whatever the
|
|
190
|
+
* token's remaining lifetime. Process memory only: a restarted runner relies
|
|
191
|
+
* on the short capability lifetime plus the server re-pushing. */
|
|
192
|
+
export class VaultRevocations {
|
|
193
|
+
min = new Map();
|
|
194
|
+
revoke(vaultId, grantId, revocationVersion) {
|
|
195
|
+
if (!vaultId || !grantId || !Number.isFinite(revocationVersion))
|
|
196
|
+
return;
|
|
197
|
+
const k = `${vaultId}\u0000${grantId}`;
|
|
198
|
+
this.min.set(k, Math.max(this.min.get(k) ?? -Infinity, revocationVersion));
|
|
199
|
+
}
|
|
200
|
+
isRevoked(g) {
|
|
201
|
+
const m = this.min.get(`${g.vault_id}\u0000${g.grant_id}`);
|
|
202
|
+
return m !== undefined && !(Number(g.revocation_version) >= m);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** Steps 2-5 of the enforcement order for one already-verified capability.
|
|
206
|
+
* Returns the grant to serve, or the error envelope. */
|
|
207
|
+
export function authorizeVaultOp(payload, input, ctx) {
|
|
208
|
+
const pick = selectVaultGrant(payload, input.vault);
|
|
209
|
+
if (!pick.ok)
|
|
210
|
+
return pick;
|
|
211
|
+
const g = pick.grant;
|
|
212
|
+
if (!OP_NAME_RE.test(input.op || ""))
|
|
213
|
+
return { ok: false, error: vaultErr("invalid", "Unknown vault operation.") };
|
|
214
|
+
if (VAULT_DENIED_OPS.has(input.op))
|
|
215
|
+
return { ok: false, error: vaultErr("forbidden", `${input.op} cannot be called directly.`) };
|
|
216
|
+
const ops = Array.isArray(g.ops) ? g.ops : [];
|
|
217
|
+
if (isVaultWriteOp(input.op)) {
|
|
218
|
+
if (!ops.includes("write"))
|
|
219
|
+
return { ok: false, error: vaultErr("forbidden", "This vault is read-only for this run.") };
|
|
220
|
+
}
|
|
221
|
+
else if (!ops.includes("read") && !ops.includes("write")) {
|
|
222
|
+
return { ok: false, error: vaultErr("forbidden", "This run has no access to that vault.") };
|
|
223
|
+
}
|
|
224
|
+
// Developer tools (eval, dev:*) only when the owner turned them on (policy dev_tools).
|
|
225
|
+
if (APP_DEV_OPS.has(input.op) && g.dev_tools !== true) {
|
|
226
|
+
return { ok: false, error: vaultErr("forbidden", "Developer tools are off for this vault. The owner can turn them on in the vault's settings.") };
|
|
227
|
+
}
|
|
228
|
+
if (ctx.via === "server" && g.relay !== true) {
|
|
229
|
+
return { ok: false, error: vaultErr("policy_route", "The owner does not allow this vault's notes to transit Melaya; run it on this computer with a local model.") };
|
|
230
|
+
}
|
|
231
|
+
const models = Array.isArray(payload.models) ? payload.models : [];
|
|
232
|
+
if (g.external_models === false && models.includes("external")) {
|
|
233
|
+
return { ok: false, error: vaultErr("policy_route", "The owner does not allow this vault's notes to reach external model providers.") };
|
|
234
|
+
}
|
|
235
|
+
if (ctx.revocations.isRevoked(g))
|
|
236
|
+
return { ok: false, error: vaultErr("forbidden", "Access to this vault was revoked.") };
|
|
237
|
+
return { ok: true, grant: g };
|
|
238
|
+
}
|
|
239
|
+
function pathApi(platform) {
|
|
240
|
+
return platform === "win32" ? path.win32 : path.posix;
|
|
241
|
+
}
|
|
242
|
+
function caseFold(platform, p) {
|
|
243
|
+
// NTFS and APFS are case-insensitive by default: compare folded.
|
|
244
|
+
return platform === "win32" || platform === "darwin" ? p.toLowerCase() : p;
|
|
245
|
+
}
|
|
246
|
+
function samePath(platform, a, b) {
|
|
247
|
+
const P = pathApi(platform);
|
|
248
|
+
return caseFold(platform, P.resolve(a)) === caseFold(platform, P.resolve(b));
|
|
249
|
+
}
|
|
250
|
+
/** True iff `child` is `parent` or inside it. */
|
|
251
|
+
export function isInsidePath(platform, child, parent) {
|
|
252
|
+
const P = pathApi(platform);
|
|
253
|
+
const rel = P.relative(caseFold(platform, P.resolve(parent)), caseFold(platform, P.resolve(child)));
|
|
254
|
+
return rel === "" || (!rel.startsWith("..") && !P.isAbsolute(rel));
|
|
255
|
+
}
|
|
256
|
+
function systemFolders(platform, home) {
|
|
257
|
+
const P = pathApi(platform);
|
|
258
|
+
if (platform === "win32") {
|
|
259
|
+
const sysDrive = (process.env.SystemDrive || "C:") + "\\";
|
|
260
|
+
const env = (k, d) => process.env[k] || d;
|
|
261
|
+
return {
|
|
262
|
+
tree: [
|
|
263
|
+
env("SystemRoot", P.join(sysDrive, "Windows")),
|
|
264
|
+
env("ProgramFiles", P.join(sysDrive, "Program Files")),
|
|
265
|
+
env("ProgramFiles(x86)", P.join(sysDrive, "Program Files (x86)")),
|
|
266
|
+
env("ProgramData", P.join(sysDrive, "ProgramData")),
|
|
267
|
+
P.join(sysDrive, "$Recycle.Bin"),
|
|
268
|
+
P.join(sysDrive, "System Volume Information"),
|
|
269
|
+
P.join(sysDrive, "Recovery"),
|
|
270
|
+
P.join(home, "AppData"),
|
|
271
|
+
],
|
|
272
|
+
exact: [],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const tree = ["/System", "/Library", "/usr", "/etc", "/bin", "/sbin", "/var", "/private", "/dev", "/proc", "/sys", "/boot", "/lib", "/lib64", "/opt", "/root", "/snap", "/Applications", "/cores"];
|
|
276
|
+
const exact = ["/Volumes", "/mnt", "/media", "/tmp", "/home", "/Users"];
|
|
277
|
+
if (platform === "darwin") {
|
|
278
|
+
// ~/Library holds iCloud Drive vaults (Mobile Documents), so only the
|
|
279
|
+
// folder itself and its credential stores are refused.
|
|
280
|
+
exact.push(P.join(home, "Library"), P.join(home, "Library", "Mobile Documents"));
|
|
281
|
+
tree.push(P.join(home, "Library", "Keychains"), P.join(home, "Library", "Application Support"));
|
|
282
|
+
}
|
|
283
|
+
return { tree, exact };
|
|
284
|
+
}
|
|
285
|
+
/** Pure check of an already-resolved real path. */
|
|
286
|
+
export function checkVaultFolderPolicy(real, opts = {}) {
|
|
287
|
+
const platform = opts.platform ?? osPlatform();
|
|
288
|
+
const P = pathApi(platform);
|
|
289
|
+
const home = opts.home ?? homedir();
|
|
290
|
+
const runnerDir = opts.runnerDir ?? P.join(home, ".melaya-runner");
|
|
291
|
+
if (!real || !P.isAbsolute(real))
|
|
292
|
+
return "forbidden_path";
|
|
293
|
+
if (platform === "win32" && (/^\\\\/.test(real) || /^\/\//.test(real)))
|
|
294
|
+
return "forbidden_path"; // UNC / device paths
|
|
295
|
+
if (samePath(platform, real, P.parse(P.resolve(real)).root))
|
|
296
|
+
return "forbidden_path"; // a filesystem root
|
|
297
|
+
// The home folder itself and anything that contains it (C:\Users, /home).
|
|
298
|
+
if (isInsidePath(platform, home, real))
|
|
299
|
+
return "forbidden_path";
|
|
300
|
+
// The runner's own config, venv, shared bundle and vault indexes.
|
|
301
|
+
if (isInsidePath(platform, real, runnerDir) || isInsidePath(platform, runnerDir, real))
|
|
302
|
+
return "forbidden_path";
|
|
303
|
+
// Dot-folders directly under home (~/.ssh, ~/.aws, ~/.config, ...).
|
|
304
|
+
if (isInsidePath(platform, real, home)) {
|
|
305
|
+
const first = P.relative(P.resolve(home), P.resolve(real)).split(/[\\/]/)[0] || "";
|
|
306
|
+
if (first.startsWith("."))
|
|
307
|
+
return "forbidden_path";
|
|
308
|
+
}
|
|
309
|
+
const sys = systemFolders(platform, home);
|
|
310
|
+
for (const t of [...sys.tree, ...(opts.extraForbidden ?? [])])
|
|
311
|
+
if (isInsidePath(platform, real, t))
|
|
312
|
+
return "forbidden_path";
|
|
313
|
+
for (const e of sys.exact)
|
|
314
|
+
if (samePath(platform, real, e))
|
|
315
|
+
return "forbidden_path";
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
/** Resolve a picked folder to the real path the engine will serve, refusing
|
|
319
|
+
* symlink / junction roots, non-directories and protected locations. */
|
|
320
|
+
export function resolveVaultFolder(input, opts = {}) {
|
|
321
|
+
const platform = opts.platform ?? osPlatform();
|
|
322
|
+
const P = pathApi(platform);
|
|
323
|
+
const raw = String(input || "").trim();
|
|
324
|
+
if (!raw || raw.includes("\u0000") || !P.isAbsolute(raw))
|
|
325
|
+
return { ok: false, error: "forbidden_path" };
|
|
326
|
+
if (platform === "win32" && /^(\\\\|\/\/)/.test(raw))
|
|
327
|
+
return { ok: false, error: "forbidden_path" };
|
|
328
|
+
const abs = P.resolve(raw);
|
|
329
|
+
let st;
|
|
330
|
+
try {
|
|
331
|
+
st = lstatSync(abs);
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return { ok: false, error: "not_a_folder" };
|
|
335
|
+
}
|
|
336
|
+
// lstat reports Windows junctions and mount points as symbolic links too.
|
|
337
|
+
if (st.isSymbolicLink())
|
|
338
|
+
return { ok: false, error: "forbidden_path" };
|
|
339
|
+
if (!st.isDirectory())
|
|
340
|
+
return { ok: false, error: "not_a_folder" };
|
|
341
|
+
let real;
|
|
342
|
+
try {
|
|
343
|
+
real = realpathSync.native(abs);
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
return { ok: false, error: "not_a_folder" };
|
|
347
|
+
}
|
|
348
|
+
let realHome = opts.home ?? homedir();
|
|
349
|
+
try {
|
|
350
|
+
realHome = realpathSync.native(realHome);
|
|
351
|
+
}
|
|
352
|
+
catch { /* keep as given */ }
|
|
353
|
+
const policy = checkVaultFolderPolicy(real, { ...opts, platform, home: realHome, runnerDir: opts.runnerDir ?? P.join(realHome, ".melaya-runner") });
|
|
354
|
+
if (policy)
|
|
355
|
+
return { ok: false, error: policy };
|
|
356
|
+
return { ok: true, real };
|
|
357
|
+
}
|
|
358
|
+
// ── Registry (~/.melaya-runner/vaults/registry.json) ─────────────────────
|
|
359
|
+
export function defaultVaultsDir() {
|
|
360
|
+
return path.join(homedir(), ".melaya-runner", "vaults");
|
|
361
|
+
}
|
|
362
|
+
function cleanGlobs(v) {
|
|
363
|
+
if (!Array.isArray(v))
|
|
364
|
+
return [];
|
|
365
|
+
return v.filter((x) => typeof x === "string" && x.length > 0 && x.length <= 512 && !x.includes("\u0000")).slice(0, 200);
|
|
366
|
+
}
|
|
367
|
+
/** Validate a registration coming from the server. Path policy is separate. */
|
|
368
|
+
export function normalizeRegistration(v) {
|
|
369
|
+
const o = (v ?? {});
|
|
370
|
+
const id = typeof o.id === "string" ? o.id : "";
|
|
371
|
+
if (!VAULT_ID_RE.test(id))
|
|
372
|
+
return null;
|
|
373
|
+
const p = typeof o.path === "string" ? o.path : "";
|
|
374
|
+
if (!p)
|
|
375
|
+
return null;
|
|
376
|
+
let cfg = typeof o.configDirName === "string" && o.configDirName.trim() ? o.configDirName.trim() : ".obsidian";
|
|
377
|
+
// One plain folder name inside the vault, never a path.
|
|
378
|
+
if (cfg.length > 64 || /[\\/:\u0000]/.test(cfg) || cfg === "." || cfg === "..")
|
|
379
|
+
cfg = ".obsidian";
|
|
380
|
+
return {
|
|
381
|
+
id,
|
|
382
|
+
label: typeof o.label === "string" ? o.label.slice(0, 200) : id,
|
|
383
|
+
path: p,
|
|
384
|
+
configDirName: cfg,
|
|
385
|
+
include: cleanGlobs(o.include),
|
|
386
|
+
exclude: cleanGlobs(o.exclude),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
export class VaultRegistry {
|
|
390
|
+
dir;
|
|
391
|
+
file;
|
|
392
|
+
vaults = new Map();
|
|
393
|
+
constructor(dir = defaultVaultsDir()) {
|
|
394
|
+
this.dir = dir;
|
|
395
|
+
this.file = path.join(dir, "registry.json");
|
|
396
|
+
}
|
|
397
|
+
load() {
|
|
398
|
+
this.vaults.clear();
|
|
399
|
+
try {
|
|
400
|
+
if (!existsSync(this.file))
|
|
401
|
+
return;
|
|
402
|
+
const parsed = JSON.parse(readFileSync(this.file, "utf-8"));
|
|
403
|
+
for (const v of Array.isArray(parsed?.vaults) ? parsed.vaults : []) {
|
|
404
|
+
const reg = normalizeRegistration(v);
|
|
405
|
+
if (reg)
|
|
406
|
+
this.vaults.set(reg.id, reg);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch { /* a corrupt registry is rebuilt by the next server register */ }
|
|
410
|
+
}
|
|
411
|
+
list() { return Array.from(this.vaults.values()); }
|
|
412
|
+
get(id) { return this.vaults.get(id); }
|
|
413
|
+
upsert(reg) {
|
|
414
|
+
this.vaults.set(reg.id, reg);
|
|
415
|
+
this.save();
|
|
416
|
+
}
|
|
417
|
+
remove(id) {
|
|
418
|
+
const had = this.vaults.delete(id);
|
|
419
|
+
if (had)
|
|
420
|
+
this.save();
|
|
421
|
+
return had;
|
|
422
|
+
}
|
|
423
|
+
/** Index folder of one vault (outside the vault). */
|
|
424
|
+
indexDir(id) {
|
|
425
|
+
if (!VAULT_ID_RE.test(id))
|
|
426
|
+
throw new Error("bad vault id");
|
|
427
|
+
return path.join(this.dir, id);
|
|
428
|
+
}
|
|
429
|
+
save() {
|
|
430
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
431
|
+
const tmp = `${this.file}.${process.pid}.tmp`;
|
|
432
|
+
writeFileSync(tmp, JSON.stringify({ vaults: this.list() }, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
433
|
+
renameSync(tmp, this.file);
|
|
434
|
+
try {
|
|
435
|
+
chmodSync(this.file, 0o600);
|
|
436
|
+
}
|
|
437
|
+
catch { /* not supported on this FS */ }
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/** Per-runner HMAC key for engine cursors (stable across host restarts). */
|
|
441
|
+
function loadCursorKey(dir) {
|
|
442
|
+
const f = path.join(dir, "cursor.key");
|
|
443
|
+
try {
|
|
444
|
+
const v = readFileSync(f, "utf-8").trim();
|
|
445
|
+
if (/^[a-f0-9]{64}$/.test(v))
|
|
446
|
+
return v;
|
|
447
|
+
}
|
|
448
|
+
catch { /* create below */ }
|
|
449
|
+
const fresh = randomBytes(32).toString("hex");
|
|
450
|
+
try {
|
|
451
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
452
|
+
writeFileSync(f, fresh, { encoding: "utf-8", mode: 0o600 });
|
|
453
|
+
}
|
|
454
|
+
catch { /* in-memory for this process */ }
|
|
455
|
+
return fresh;
|
|
456
|
+
}
|
|
457
|
+
/** JSON-lines RPC to one long-lived `python -u localVault.py`, started lazily
|
|
458
|
+
* and restarted with backoff when it dies while vaults are registered. */
|
|
459
|
+
export class VaultHostProcess {
|
|
460
|
+
o;
|
|
461
|
+
proc = null;
|
|
462
|
+
starting = null;
|
|
463
|
+
seq = 0;
|
|
464
|
+
buf = "";
|
|
465
|
+
pending = new Map();
|
|
466
|
+
eventCbs = [];
|
|
467
|
+
restartCbs = [];
|
|
468
|
+
backoffMs = 1000;
|
|
469
|
+
stopped = false;
|
|
470
|
+
spawns = 0;
|
|
471
|
+
restartTimer = null;
|
|
472
|
+
stderrTail = [];
|
|
473
|
+
constructor(o) {
|
|
474
|
+
this.o = o;
|
|
475
|
+
}
|
|
476
|
+
onEvent(cb) { this.eventCbs.push(cb); }
|
|
477
|
+
onRestart(cb) { this.restartCbs.push(cb); }
|
|
478
|
+
async ensure() {
|
|
479
|
+
if (this.proc && this.proc.exitCode === null && !this.proc.killed)
|
|
480
|
+
return this.proc;
|
|
481
|
+
if (this.starting)
|
|
482
|
+
return this.starting;
|
|
483
|
+
this.starting = (async () => {
|
|
484
|
+
const l = await this.o.launch();
|
|
485
|
+
const p = spawn(l.pythonPath, ["-u", l.scriptPath], { env: l.env, cwd: l.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
486
|
+
// EPIPE after a crash surfaces here; the exit handler does the cleanup.
|
|
487
|
+
p.stdin?.on("error", () => { });
|
|
488
|
+
p.stdout?.setEncoding("utf-8");
|
|
489
|
+
p.stdout?.on("data", (d) => this.onStdout(d));
|
|
490
|
+
p.stderr?.on("data", (d) => {
|
|
491
|
+
for (const line of d.toString("utf-8").split(/\r?\n/)) {
|
|
492
|
+
if (!line.trim())
|
|
493
|
+
continue;
|
|
494
|
+
this.stderrTail.push(line.slice(0, 300));
|
|
495
|
+
if (this.stderrTail.length > 20)
|
|
496
|
+
this.stderrTail.shift();
|
|
497
|
+
this.o.log?.(`host: ${line.slice(0, 300)}`);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
p.on("error", (e) => this.onExit(p, `spawn failed: ${e.message}`));
|
|
501
|
+
p.on("exit", (code) => this.onExit(p, `exited ${code ?? "?"}`));
|
|
502
|
+
this.proc = p;
|
|
503
|
+
// A fresh process after a crash knows no vault: let the service
|
|
504
|
+
// re-register them (requests that race this are retried by the service).
|
|
505
|
+
if (++this.spawns > 1) {
|
|
506
|
+
setImmediate(() => { for (const cb of this.restartCbs) {
|
|
507
|
+
try {
|
|
508
|
+
cb();
|
|
509
|
+
}
|
|
510
|
+
catch { /* listener bug */ }
|
|
511
|
+
} });
|
|
512
|
+
}
|
|
513
|
+
return p;
|
|
514
|
+
})();
|
|
515
|
+
try {
|
|
516
|
+
return await this.starting;
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
this.starting = null;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
onStdout(d) {
|
|
523
|
+
this.buf += d;
|
|
524
|
+
if (this.buf.length > 64 * 1024 * 1024)
|
|
525
|
+
this.buf = ""; // OOM backstop
|
|
526
|
+
let i;
|
|
527
|
+
while ((i = this.buf.indexOf("\n")) >= 0) {
|
|
528
|
+
const line = this.buf.slice(0, i).trim();
|
|
529
|
+
this.buf = this.buf.slice(i + 1);
|
|
530
|
+
if (!line.startsWith("{"))
|
|
531
|
+
continue;
|
|
532
|
+
let msg;
|
|
533
|
+
try {
|
|
534
|
+
msg = JSON.parse(line);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (msg?.event === "status" && typeof msg.vaultId === "string") {
|
|
540
|
+
const { event: _e, ...ev } = msg;
|
|
541
|
+
for (const cb of this.eventCbs) {
|
|
542
|
+
try {
|
|
543
|
+
cb(ev);
|
|
544
|
+
}
|
|
545
|
+
catch { /* listener bug */ }
|
|
546
|
+
}
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (msg?.event === "ready") {
|
|
550
|
+
this.backoffMs = 1000;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const entry = typeof msg?.id === "string" ? this.pending.get(msg.id) : undefined;
|
|
554
|
+
if (!entry)
|
|
555
|
+
continue;
|
|
556
|
+
this.pending.delete(msg.id);
|
|
557
|
+
clearTimeout(entry.timer);
|
|
558
|
+
if (msg.ok === true)
|
|
559
|
+
entry.resolve(msg.result);
|
|
560
|
+
else
|
|
561
|
+
entry.reject(new Error(String(msg.error || "host_error").slice(0, 300)));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
onExit(p, why) {
|
|
565
|
+
if (this.proc !== p)
|
|
566
|
+
return;
|
|
567
|
+
this.proc = null;
|
|
568
|
+
this.buf = "";
|
|
569
|
+
for (const [id, e] of this.pending) {
|
|
570
|
+
clearTimeout(e.timer);
|
|
571
|
+
e.reject(new Error("vault_host_crashed"));
|
|
572
|
+
this.pending.delete(id);
|
|
573
|
+
}
|
|
574
|
+
if (this.stopped)
|
|
575
|
+
return;
|
|
576
|
+
this.o.log?.(`vault host ${why}`);
|
|
577
|
+
if (!this.o.wanted() || this.restartTimer)
|
|
578
|
+
return;
|
|
579
|
+
const delay = this.backoffMs;
|
|
580
|
+
this.backoffMs = Math.min(60_000, this.backoffMs * 2);
|
|
581
|
+
this.restartTimer = setTimeout(() => {
|
|
582
|
+
this.restartTimer = null;
|
|
583
|
+
if (this.stopped || !this.o.wanted())
|
|
584
|
+
return;
|
|
585
|
+
this.ensure().catch((e) => this.o.log?.(`vault host restart failed: ${e?.message || e}`));
|
|
586
|
+
}, delay);
|
|
587
|
+
this.restartTimer.unref?.();
|
|
588
|
+
}
|
|
589
|
+
async request(cmd, payload, timeoutMs = 60_000) {
|
|
590
|
+
const p = await this.ensure();
|
|
591
|
+
const id = `v${++this.seq}`;
|
|
592
|
+
return new Promise((resolve, reject) => {
|
|
593
|
+
const timer = setTimeout(() => {
|
|
594
|
+
this.pending.delete(id);
|
|
595
|
+
reject(new Error("vault_host_timeout"));
|
|
596
|
+
}, timeoutMs);
|
|
597
|
+
timer.unref?.();
|
|
598
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
599
|
+
try {
|
|
600
|
+
p.stdin?.write(JSON.stringify({ id, cmd, ...payload }) + "\n");
|
|
601
|
+
}
|
|
602
|
+
catch (e) {
|
|
603
|
+
clearTimeout(timer);
|
|
604
|
+
this.pending.delete(id);
|
|
605
|
+
reject(new Error(`vault_host_write_failed: ${e?.message || e}`));
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
stop() {
|
|
610
|
+
this.stopped = true;
|
|
611
|
+
if (this.restartTimer)
|
|
612
|
+
clearTimeout(this.restartTimer);
|
|
613
|
+
try {
|
|
614
|
+
this.proc?.stdin?.end();
|
|
615
|
+
}
|
|
616
|
+
catch { /* gone */ }
|
|
617
|
+
const p = this.proc;
|
|
618
|
+
if (p)
|
|
619
|
+
setTimeout(() => { try {
|
|
620
|
+
p.kill();
|
|
621
|
+
}
|
|
622
|
+
catch { /* gone */ } }, 2000).unref?.();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const WATCH_DEBOUNCE_MS = 1500;
|
|
626
|
+
const WATCH_MAX_PATHS = 2000;
|
|
627
|
+
export class VaultService {
|
|
628
|
+
o;
|
|
629
|
+
keys = new Map();
|
|
630
|
+
revocations = new VaultRevocations();
|
|
631
|
+
cursorKey;
|
|
632
|
+
watchers = new Map();
|
|
633
|
+
lastStatus = new Map();
|
|
634
|
+
/** Cloud copy of `storage: mirror` vaults (vaultMirror.ts, plan §6.4 S4). */
|
|
635
|
+
mirror;
|
|
636
|
+
mirrorSink = null;
|
|
637
|
+
constructor(o) {
|
|
638
|
+
this.o = o;
|
|
639
|
+
this.cursorKey = loadCursorKey(o.registry.dir);
|
|
640
|
+
this.mirror = new VaultMirrorManager({
|
|
641
|
+
host: o.host,
|
|
642
|
+
isRegistered: (id) => !!o.registry.get(id),
|
|
643
|
+
apiBase: (id) => mirrorApiBase(o.serverHttpBase, id),
|
|
644
|
+
emit: (event, payload) => this.mirrorSink?.(event, payload),
|
|
645
|
+
log: o.log,
|
|
646
|
+
timers: o.watch !== false,
|
|
647
|
+
});
|
|
648
|
+
o.host.onEvent((ev) => {
|
|
649
|
+
if (!o.registry.get(ev.vaultId))
|
|
650
|
+
return; // a late event for an unregistered vault
|
|
651
|
+
this.lastStatus.set(ev.vaultId, ev);
|
|
652
|
+
o.emitStatus?.(ev);
|
|
653
|
+
this.mirror.onIndexStatus(ev);
|
|
654
|
+
});
|
|
655
|
+
o.host.onRestart(() => { void this.restore(); });
|
|
656
|
+
}
|
|
657
|
+
/** Where mirror renew requests and statuses go (the runner socket). */
|
|
658
|
+
setMirrorSink(sink) {
|
|
659
|
+
this.mirrorSink = sink;
|
|
660
|
+
}
|
|
661
|
+
get rnrId() { return this.o.rnrId; }
|
|
662
|
+
hasKeys() { return this.keys.size > 0; }
|
|
663
|
+
/** runner:vaultKeys — replaces the key set (the server sends all current kids). */
|
|
664
|
+
setKeys(keys) {
|
|
665
|
+
const parsed = parseVaultKeys(keys);
|
|
666
|
+
if (parsed.size)
|
|
667
|
+
this.keys = parsed;
|
|
668
|
+
}
|
|
669
|
+
/** runner:vaultRevoke */
|
|
670
|
+
revoke(p) {
|
|
671
|
+
this.revocations.revoke(String(p?.vaultId || ""), String(p?.grantId || ""), Number(p?.revocationVersion));
|
|
672
|
+
}
|
|
673
|
+
/** Statuses to re-send after a reconnect. */
|
|
674
|
+
latestStatuses() { return Array.from(this.lastStatus.values()); }
|
|
675
|
+
hostRegisterPayload(reg) {
|
|
676
|
+
const dir = this.o.registry.indexDir(reg.id);
|
|
677
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
678
|
+
return {
|
|
679
|
+
vault: {
|
|
680
|
+
id: reg.id,
|
|
681
|
+
label: reg.label,
|
|
682
|
+
root: reg.path,
|
|
683
|
+
configDirName: reg.configDirName,
|
|
684
|
+
include: reg.include,
|
|
685
|
+
exclude: reg.exclude,
|
|
686
|
+
indexPath: path.join(dir, "index.sqlite"),
|
|
687
|
+
},
|
|
688
|
+
cursorKey: this.cursorKey,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
/** runner:vault-register */
|
|
692
|
+
async register(input) {
|
|
693
|
+
const reg = normalizeRegistration(input);
|
|
694
|
+
if (!reg)
|
|
695
|
+
return { ok: false, error: "invalid" };
|
|
696
|
+
const folder = resolveVaultFolder(reg.path, this.o.folderPolicy);
|
|
697
|
+
if (!folder.ok)
|
|
698
|
+
return { ok: false, error: folder.error };
|
|
699
|
+
const resolved = { ...reg, path: folder.real };
|
|
700
|
+
let data;
|
|
701
|
+
try {
|
|
702
|
+
data = await this.o.host.request("register", this.hostRegisterPayload(resolved), 30 * 60_000);
|
|
703
|
+
}
|
|
704
|
+
catch (e) {
|
|
705
|
+
return { ok: false, error: String(e?.message || "vault_host_failed").slice(0, 300) };
|
|
706
|
+
}
|
|
707
|
+
this.o.registry.upsert(resolved);
|
|
708
|
+
this.startWatch(resolved);
|
|
709
|
+
const out = {
|
|
710
|
+
notes: Number(data?.notes) || 0,
|
|
711
|
+
bytes: Number(data?.bytes) || 0,
|
|
712
|
+
indexRev: Number(data?.indexRev) || 0,
|
|
713
|
+
capabilities: await this.withAppCapabilities(resolved, data?.capabilities),
|
|
714
|
+
};
|
|
715
|
+
this.lastStatus.set(reg.id, { vaultId: reg.id, status: "ready", ...out });
|
|
716
|
+
return { ok: true, data: out };
|
|
717
|
+
}
|
|
718
|
+
/** runner:vault-unregister — also deletes the derived index (it holds note text). */
|
|
719
|
+
async unregister(vaultId) {
|
|
720
|
+
const id = String(vaultId || "");
|
|
721
|
+
if (!VAULT_ID_RE.test(id))
|
|
722
|
+
return { ok: false, error: "invalid" };
|
|
723
|
+
this.stopWatch(id);
|
|
724
|
+
this.lastStatus.delete(id);
|
|
725
|
+
this.mirror.forget(id);
|
|
726
|
+
const had = this.o.registry.get(id);
|
|
727
|
+
this.o.registry.remove(id);
|
|
728
|
+
if (had) {
|
|
729
|
+
try {
|
|
730
|
+
await this.o.host.request("unregister", { vaultId: id }, 30_000);
|
|
731
|
+
}
|
|
732
|
+
catch { /* host gone: nothing open */ }
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
const dir = this.o.registry.indexDir(id);
|
|
736
|
+
if (isInsidePath(process.platform, dir, this.o.registry.dir) && !samePath(process.platform, dir, this.o.registry.dir)) {
|
|
737
|
+
rmSync(dir, { recursive: true, force: true });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
catch { /* best-effort */ }
|
|
741
|
+
return { ok: true, data: { removed: !!had } };
|
|
742
|
+
}
|
|
743
|
+
/** Re-open every registered vault (startup, host restart). */
|
|
744
|
+
async restore() {
|
|
745
|
+
for (const reg of this.o.registry.list()) {
|
|
746
|
+
const folder = resolveVaultFolder(reg.path, this.o.folderPolicy);
|
|
747
|
+
if (!folder.ok) {
|
|
748
|
+
const ev = { vaultId: reg.id, status: "error", error: folder.error };
|
|
749
|
+
this.lastStatus.set(reg.id, ev);
|
|
750
|
+
this.o.emitStatus?.(ev);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const data = await this.o.host.request("register", this.hostRegisterPayload({ ...reg, path: folder.real }), 30 * 60_000);
|
|
755
|
+
const ev = { vaultId: reg.id, status: "ready", notes: Number(data?.notes) || 0, bytes: Number(data?.bytes) || 0, indexRev: Number(data?.indexRev) || 0, capabilities: await this.withAppCapabilities({ ...reg, path: folder.real }, data?.capabilities) };
|
|
756
|
+
this.lastStatus.set(reg.id, ev);
|
|
757
|
+
this.o.emitStatus?.(ev);
|
|
758
|
+
this.startWatch({ ...reg, path: folder.real });
|
|
759
|
+
}
|
|
760
|
+
catch (e) {
|
|
761
|
+
const ev = { vaultId: reg.id, status: "error", error: String(e?.message || "vault_host_failed").slice(0, 300) };
|
|
762
|
+
this.lastStatus.set(reg.id, ev);
|
|
763
|
+
this.o.emitStatus?.(ev);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** Verify a capability and serve one op for a vault held on THIS runner. */
|
|
768
|
+
async op(input, via) {
|
|
769
|
+
const audiences = via === "server" ? [`runner:${this.o.rnrId}`, "vault-api"] : [`runner:${this.o.rnrId}`];
|
|
770
|
+
const token = String(input.capability || "");
|
|
771
|
+
const maxLife = via === "local" && isTrustedRunVaultCapability(token) ? RUN_CAP_MAX_LIFETIME_S : undefined;
|
|
772
|
+
const cap = verifyVaultCapability(token, this.keys, audiences, undefined, maxLife);
|
|
773
|
+
if (!cap.ok)
|
|
774
|
+
return vaultErr(cap.code, `The vault capability was refused (${cap.reason}).`);
|
|
775
|
+
const op = String(input.op || "");
|
|
776
|
+
const auth = authorizeVaultOp(cap.payload, { vault: input.vault, op }, { via, revocations: this.revocations });
|
|
777
|
+
if (!auth.ok)
|
|
778
|
+
return auth.error;
|
|
779
|
+
const g = auth.grant;
|
|
780
|
+
if (g.source !== "device" || g.rnr_id !== this.o.rnrId) {
|
|
781
|
+
return vaultErr("forbidden", "That vault is not held by this computer.");
|
|
782
|
+
}
|
|
783
|
+
if (!this.o.registry.get(g.vault_id)) {
|
|
784
|
+
return vaultErr("not_found", "That vault is not registered on this computer; reconnect it in Melaya.");
|
|
785
|
+
}
|
|
786
|
+
let args = (input.args && typeof input.args === "object" && !Array.isArray(input.args) ? input.args : {});
|
|
787
|
+
const idem = typeof input.idempotency_key === "string" ? input.idempotency_key.slice(0, 200) : null;
|
|
788
|
+
// App ops (obsidianApp.ts): through the user's running Obsidian, or back
|
|
789
|
+
// to the files engine for a Files+App op when the app is not reachable.
|
|
790
|
+
const reg = this.o.registry.get(g.vault_id);
|
|
791
|
+
const appGrant = { ops: g.ops, folders: Array.isArray(g.folders) ? g.folders : [], dev_tools: g.dev_tools === true };
|
|
792
|
+
if (this.o.app) {
|
|
793
|
+
const served = await this.o.app.serve({ reg, grant: appGrant, op, args, idempotencyKey: idem }).catch(() => null);
|
|
794
|
+
if (served?.kind === "env")
|
|
795
|
+
return served.env;
|
|
796
|
+
if (served?.kind === "files")
|
|
797
|
+
args = served.args;
|
|
798
|
+
}
|
|
799
|
+
else if (APP_ONLY_OPS.has(op)) {
|
|
800
|
+
return vaultErr("unsupported", "This runner cannot reach the Obsidian app; update the Melaya runner.");
|
|
801
|
+
}
|
|
802
|
+
const env = await this.hostOp(g, op, args, idem);
|
|
803
|
+
if (this.o.app && op === "base_query") {
|
|
804
|
+
const viaApp = await this.o.app.afterFiles({ reg, grant: appGrant, op, args }, env).catch(() => null);
|
|
805
|
+
if (viaApp)
|
|
806
|
+
return viaApp;
|
|
807
|
+
}
|
|
808
|
+
if (this.o.app && op === "capabilities" && env.ok) {
|
|
809
|
+
const st = await this.o.app.status(reg).catch(() => null);
|
|
810
|
+
if (st) {
|
|
811
|
+
const e = env;
|
|
812
|
+
if (Array.isArray(e.items))
|
|
813
|
+
e.items = [...e.items, ...st.features];
|
|
814
|
+
e.app = { cli: st.cli, rest: st.rest, version: st.version, features: st.features };
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return env;
|
|
818
|
+
}
|
|
819
|
+
/** One engine op on the Python host (re-registers once after a host restart). */
|
|
820
|
+
async hostOp(g, op, args, idem) {
|
|
821
|
+
const req = {
|
|
822
|
+
vaultId: g.vault_id,
|
|
823
|
+
op,
|
|
824
|
+
args,
|
|
825
|
+
folders: Array.isArray(g.folders) ? g.folders.filter((f) => typeof f === "string") : [],
|
|
826
|
+
write: isVaultWriteOp(op),
|
|
827
|
+
idempotency_key: idem,
|
|
828
|
+
};
|
|
829
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
830
|
+
try {
|
|
831
|
+
const env = await this.o.host.request("op", req, 110_000);
|
|
832
|
+
if (env && typeof env === "object" && typeof env.ok === "boolean")
|
|
833
|
+
return env;
|
|
834
|
+
return vaultErr("invalid", "The vault host returned an empty answer.");
|
|
835
|
+
}
|
|
836
|
+
catch (e) {
|
|
837
|
+
const m = String(e?.message || "");
|
|
838
|
+
// A host that restarted since registration does not know the vault
|
|
839
|
+
// yet: re-open it once and retry.
|
|
840
|
+
if (m === "unknown_vault" && attempt === 0) {
|
|
841
|
+
const reg = this.o.registry.get(g.vault_id);
|
|
842
|
+
const folder = reg ? resolveVaultFolder(reg.path, this.o.folderPolicy) : null;
|
|
843
|
+
if (!reg || !folder?.ok)
|
|
844
|
+
return vaultErr("not_found", "That vault's folder is no longer available on this computer.");
|
|
845
|
+
try {
|
|
846
|
+
await this.o.host.request("register", this.hostRegisterPayload({ ...reg, path: folder.real }), 30 * 60_000);
|
|
847
|
+
}
|
|
848
|
+
catch {
|
|
849
|
+
return vaultErr("vault_offline", "The vault host on this computer could not reopen the vault.");
|
|
850
|
+
}
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
if (m === "vault_host_timeout")
|
|
854
|
+
return vaultErr("vault_offline", "The vault host on this computer did not answer in time.");
|
|
855
|
+
return vaultErr("vault_offline", "The vault host on this computer is not running; it restarts automatically.");
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return vaultErr("vault_offline", "The vault host on this computer could not serve the request.");
|
|
859
|
+
}
|
|
860
|
+
/** Merge the Obsidian app's availability into a vault's capability report
|
|
861
|
+
* (stored by the server as agents.vaults.capabilities). */
|
|
862
|
+
async withAppCapabilities(reg, caps) {
|
|
863
|
+
const base = caps && typeof caps === "object" && !Array.isArray(caps) ? { ...caps } : {};
|
|
864
|
+
if (!this.o.app)
|
|
865
|
+
return base;
|
|
866
|
+
const st = await this.o.app.status(reg).catch(() => null);
|
|
867
|
+
if (!st)
|
|
868
|
+
return base;
|
|
869
|
+
return { ...base, app_bridge: st.features.length > 0, app: { cli: st.cli, rest: st.rest, version: st.version, features: st.features } };
|
|
870
|
+
}
|
|
871
|
+
/** runner:vault-app — the owner's vault settings asking about / connecting
|
|
872
|
+
* the Obsidian app on this computer. The Local REST API key is read from
|
|
873
|
+
* the plugin's own settings HERE and stays here: only states and a short
|
|
874
|
+
* certificate fingerprint go back. */
|
|
875
|
+
async appControl(p) {
|
|
876
|
+
const id = String(p?.vaultId || "");
|
|
877
|
+
if (!VAULT_ID_RE.test(id))
|
|
878
|
+
return { ok: false, error: "invalid" };
|
|
879
|
+
const reg = this.o.registry.get(id);
|
|
880
|
+
if (!reg)
|
|
881
|
+
return { ok: false, error: "not_registered" };
|
|
882
|
+
if (!this.o.app)
|
|
883
|
+
return { ok: false, error: "app_bridge_unavailable" };
|
|
884
|
+
const action = String(p?.action || "status");
|
|
885
|
+
const pub = (s) => ({
|
|
886
|
+
cli: s.cli, rest: s.rest, version: s.version, features: s.features, restFingerprint: s.restFingerprint,
|
|
887
|
+
});
|
|
888
|
+
if (action === "status") {
|
|
889
|
+
this.o.app.invalidate();
|
|
890
|
+
return { ok: true, data: pub(await this.o.app.status(reg)) };
|
|
891
|
+
}
|
|
892
|
+
if (action === "connect_rest") {
|
|
893
|
+
const r = await this.o.app.connectRest(reg);
|
|
894
|
+
return r.ok ? { ok: true, data: pub(r.status) } : { ok: false, error: r.error };
|
|
895
|
+
}
|
|
896
|
+
if (action === "disconnect_rest") {
|
|
897
|
+
this.o.app.disconnectRest(id);
|
|
898
|
+
return { ok: true, data: pub(await this.o.app.status(reg)) };
|
|
899
|
+
}
|
|
900
|
+
return { ok: false, error: "invalid" };
|
|
901
|
+
}
|
|
902
|
+
/** True iff a relay request targets a device vault held by this runner. */
|
|
903
|
+
isLocalTarget(capability, vault) {
|
|
904
|
+
const p = decodeVaultCapUnverified(capability);
|
|
905
|
+
if (!p)
|
|
906
|
+
return false;
|
|
907
|
+
const pick = selectVaultGrant(p, typeof vault === "string" ? vault : "");
|
|
908
|
+
return pick.ok && pick.grant.source === "device" && pick.grant.rnr_id === this.o.rnrId;
|
|
909
|
+
}
|
|
910
|
+
/** POST /vaults/op on the local relay (127.0.0.1). */
|
|
911
|
+
async handleRelayHttp(req, res) {
|
|
912
|
+
const send = (status, body) => {
|
|
913
|
+
if (res.headersSent)
|
|
914
|
+
return;
|
|
915
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
916
|
+
res.end(JSON.stringify(body));
|
|
917
|
+
};
|
|
918
|
+
const chunks = [];
|
|
919
|
+
let size = 0;
|
|
920
|
+
let tooBig = false;
|
|
921
|
+
try {
|
|
922
|
+
for await (const c of req) {
|
|
923
|
+
size += c.length;
|
|
924
|
+
if (size > MAX_RELAY_BODY) {
|
|
925
|
+
tooBig = true;
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
chunks.push(c);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
catch {
|
|
932
|
+
return send(400, { ok: false, error: "read_failed" });
|
|
933
|
+
}
|
|
934
|
+
if (tooBig)
|
|
935
|
+
return send(413, { ok: false, error: "too_large" });
|
|
936
|
+
const raw = Buffer.concat(chunks);
|
|
937
|
+
const h = String(req.headers.authorization || "");
|
|
938
|
+
const capability = h.startsWith("Vault ") ? h.slice(6).trim() : "";
|
|
939
|
+
if (!capability)
|
|
940
|
+
return send(401, { ok: false, error: "missing_capability" });
|
|
941
|
+
let body;
|
|
942
|
+
try {
|
|
943
|
+
body = JSON.parse(raw.toString("utf-8") || "{}");
|
|
944
|
+
}
|
|
945
|
+
catch {
|
|
946
|
+
return send(400, { ok: false, error: "invalid_json" });
|
|
947
|
+
}
|
|
948
|
+
if (!body || typeof body !== "object")
|
|
949
|
+
return send(400, { ok: false, error: "invalid_json" });
|
|
950
|
+
if (this.isLocalTarget(capability, body.vault)) {
|
|
951
|
+
const env = await this.op({ capability, op: body.op, vault: body.vault, args: body.args, idempotency_key: body.idempotency_key }, "local");
|
|
952
|
+
return send(200, env);
|
|
953
|
+
}
|
|
954
|
+
// Not ours: forward the identical request to the server's public Vault API.
|
|
955
|
+
const doFetch = this.o.fetchImpl ?? fetch;
|
|
956
|
+
try {
|
|
957
|
+
const r = await doFetch(`${this.o.serverHttpBase.replace(/\/+$/, "")}/api/v1/vault-api/op`, {
|
|
958
|
+
method: "POST",
|
|
959
|
+
headers: { "Content-Type": "application/json", Authorization: `Vault ${capability}` },
|
|
960
|
+
body: raw,
|
|
961
|
+
signal: AbortSignal.timeout(125_000),
|
|
962
|
+
});
|
|
963
|
+
res.writeHead(r.status, { "Content-Type": r.headers.get("content-type") || "application/json" });
|
|
964
|
+
if (!r.body) {
|
|
965
|
+
res.end();
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
await new Promise((resolve) => {
|
|
969
|
+
const s = Readable.fromWeb(r.body);
|
|
970
|
+
s.on("error", () => { try {
|
|
971
|
+
res.end();
|
|
972
|
+
}
|
|
973
|
+
catch { /* closed */ } resolve(); });
|
|
974
|
+
s.on("end", () => resolve());
|
|
975
|
+
s.pipe(res);
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
catch (e) {
|
|
979
|
+
send(502, { ok: false, error: e?.name === "TimeoutError" ? "timeout" : "server_unreachable" });
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
// ── change detection (plan §6.4: fs.watch → debounced queue) ──────────
|
|
983
|
+
// The Python host also runs a periodic stat-only reconciliation (10 min),
|
|
984
|
+
// which covers watcher overflow, sleep and platforms without recursive
|
|
985
|
+
// fs.watch (Linux on Node < 20).
|
|
986
|
+
startWatch(reg) {
|
|
987
|
+
this.stopWatch(reg.id);
|
|
988
|
+
if (this.o.watch === false)
|
|
989
|
+
return;
|
|
990
|
+
const st = { w: null, paths: new Set(), full: false, timer: null };
|
|
991
|
+
this.watchers.set(reg.id, st);
|
|
992
|
+
const flush = () => {
|
|
993
|
+
st.timer = null;
|
|
994
|
+
const paths = st.full ? null : Array.from(st.paths);
|
|
995
|
+
st.paths.clear();
|
|
996
|
+
st.full = false;
|
|
997
|
+
this.o.host.request("changed", { vaultId: reg.id, paths }, 10 * 60_000).catch(() => { });
|
|
998
|
+
};
|
|
999
|
+
try {
|
|
1000
|
+
st.w = fsWatch(reg.path, { recursive: true, persistent: false }, (_ev, filename) => {
|
|
1001
|
+
const name = filename ? String(filename).replace(/\\/g, "/") : "";
|
|
1002
|
+
// Dot entries (the config folder's workspace.json changes constantly,
|
|
1003
|
+
// .git, .trash) are invisible to the engine anyway.
|
|
1004
|
+
if (name && name.split("/").some((s) => s.startsWith(".")))
|
|
1005
|
+
return;
|
|
1006
|
+
if (!name || st.paths.size >= WATCH_MAX_PATHS)
|
|
1007
|
+
st.full = true;
|
|
1008
|
+
else
|
|
1009
|
+
st.paths.add(name);
|
|
1010
|
+
if (!st.timer) {
|
|
1011
|
+
st.timer = setTimeout(flush, WATCH_DEBOUNCE_MS);
|
|
1012
|
+
st.timer.unref?.();
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
st.w.on("error", () => {
|
|
1016
|
+
try {
|
|
1017
|
+
st.w?.close();
|
|
1018
|
+
}
|
|
1019
|
+
catch { /* closed */ }
|
|
1020
|
+
st.w = null;
|
|
1021
|
+
st.full = true;
|
|
1022
|
+
if (!st.timer) {
|
|
1023
|
+
st.timer = setTimeout(flush, WATCH_DEBOUNCE_MS);
|
|
1024
|
+
st.timer.unref?.();
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
catch {
|
|
1029
|
+
st.w = null; // no recursive watch here: the host's reconciliation covers it
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
stopWatch(id) {
|
|
1033
|
+
const st = this.watchers.get(id);
|
|
1034
|
+
if (!st)
|
|
1035
|
+
return;
|
|
1036
|
+
if (st.timer)
|
|
1037
|
+
clearTimeout(st.timer);
|
|
1038
|
+
try {
|
|
1039
|
+
st.w?.close();
|
|
1040
|
+
}
|
|
1041
|
+
catch { /* closed */ }
|
|
1042
|
+
this.watchers.delete(id);
|
|
1043
|
+
}
|
|
1044
|
+
stop() {
|
|
1045
|
+
for (const id of Array.from(this.watchers.keys()))
|
|
1046
|
+
this.stopWatch(id);
|
|
1047
|
+
this.mirror.stop();
|
|
1048
|
+
this.o.host.stop();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/** Wire the server → runner vault events. Every request is answered with
|
|
1052
|
+
* runner:vault-result {requestId, ok, data | envelope | error}. */
|
|
1053
|
+
export function attachVaultSocketHandlers(socket, service, pickFolder) {
|
|
1054
|
+
const reply = (requestId, r) => {
|
|
1055
|
+
if (typeof requestId !== "string" || !requestId)
|
|
1056
|
+
return;
|
|
1057
|
+
socket.emit("runner:vault-result", { requestId, ...r });
|
|
1058
|
+
};
|
|
1059
|
+
socket.on("runner:vaultKeys", (p) => service.setKeys(p?.keys));
|
|
1060
|
+
socket.on("runner:vaultRevoke", (p) => service.revoke(p ?? {}));
|
|
1061
|
+
// Cloud copy (storage: mirror): the server pushes a mirror capability per
|
|
1062
|
+
// vault on enable and on every hello; renewals and statuses go back here.
|
|
1063
|
+
service.setMirrorSink((event, payload) => { socket.emit(event, payload); });
|
|
1064
|
+
socket.on("runner:vault-mirror", (p) => {
|
|
1065
|
+
service.mirror.setGrant(p ?? {});
|
|
1066
|
+
});
|
|
1067
|
+
socket.on("runner:vault-register", async (p) => {
|
|
1068
|
+
reply(p?.requestId, await service.register(p?.vault).catch((e) => ({ ok: false, error: String(e?.message || "register_failed").slice(0, 300) })));
|
|
1069
|
+
});
|
|
1070
|
+
socket.on("runner:vault-unregister", async (p) => {
|
|
1071
|
+
reply(p?.requestId, await service.unregister(p?.vaultId).catch((e) => ({ ok: false, error: String(e?.message || "unregister_failed").slice(0, 300) })));
|
|
1072
|
+
});
|
|
1073
|
+
socket.on("runner:vault-op", async (p) => {
|
|
1074
|
+
// The runner verifies the capability itself; the server is not trusted
|
|
1075
|
+
// to bypass it. Refusals travel as an error envelope (ok:true transport).
|
|
1076
|
+
const envelope = await service.op(p ?? {}, "server").catch(() => vaultErr("vault_offline", "The vault host failed."));
|
|
1077
|
+
reply(p?.requestId, { ok: true, envelope });
|
|
1078
|
+
});
|
|
1079
|
+
socket.on("runner:vault-app", async (p) => {
|
|
1080
|
+
reply(p?.requestId, await service.appControl(p ?? {}).catch((e) => ({ ok: false, error: String(e?.message || "app_failed").slice(0, 300) })));
|
|
1081
|
+
});
|
|
1082
|
+
socket.on("runner:vault-pick-folder", async (p) => {
|
|
1083
|
+
try {
|
|
1084
|
+
reply(p?.requestId, { ok: true, data: await pickFolder() });
|
|
1085
|
+
}
|
|
1086
|
+
catch (e) {
|
|
1087
|
+
reply(p?.requestId, { ok: false, error: String(e?.message || "pick failed").slice(0, 300) });
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
// ── Tool env for runner-hosted agents ─────────────────────────────────────
|
|
1092
|
+
let _relayPort = 0;
|
|
1093
|
+
/** Called once the local relay is listening (connection.ts). */
|
|
1094
|
+
export function setVaultRelayPort(port) {
|
|
1095
|
+
_relayPort = port;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Env a runner-hosted agent needs to call vault tools (protocol §5): the
|
|
1099
|
+
* capability and the LOCAL relay URL, which serves this computer's vaults
|
|
1100
|
+
* in-process and forwards every other vault to the server.
|
|
1101
|
+
*
|
|
1102
|
+
* Must be merged LAST into the subprocess env (after payload.credentials, so a
|
|
1103
|
+
* credential named MEL_VAULT_* can never replace it) by:
|
|
1104
|
+
* - the pipeline spawn in connection.ts `runner:run` (the env object built
|
|
1105
|
+
* next to `PYTHONPATH: sharedDir` / MEL_STUDIO_URL), from the run payload's
|
|
1106
|
+
* capability;
|
|
1107
|
+
* - the assistant host, per turn, in `runner:assistant_turn` (the turn's
|
|
1108
|
+
* capability; the host is long-lived, so the value rides the turn frame,
|
|
1109
|
+
* not the boot env built by `buildAssistantEnv`).
|
|
1110
|
+
* Returns {} when there is no capability or the relay is not up yet.
|
|
1111
|
+
*/
|
|
1112
|
+
export function vaultToolEnv(capability, relayPort = _relayPort) {
|
|
1113
|
+
if (!capability || !relayPort)
|
|
1114
|
+
return {};
|
|
1115
|
+
return {
|
|
1116
|
+
MEL_VAULT_CAP: capability,
|
|
1117
|
+
MEL_VAULT_API_URL: `http://127.0.0.1:${relayPort}/vaults`,
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
/** Locate localVault.py next to the compiled module (dist/) or one level up (src tree). */
|
|
1121
|
+
export function findVaultHostScript(baseDir) {
|
|
1122
|
+
for (const c of [path.join(baseDir, "localVault.py"), path.join(baseDir, "..", "localVault.py")]) {
|
|
1123
|
+
try {
|
|
1124
|
+
if (statSync(c).isFile())
|
|
1125
|
+
return c;
|
|
1126
|
+
}
|
|
1127
|
+
catch { /* next */ }
|
|
1128
|
+
}
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|