/** * Obsidian vault host — the runner side of docs/plans/obsidian-vault-protocol.md. * * A "device vault" is a folder on THIS computer, registered by the server for * this runner's stable install id (rnr_…). The runner: * - keeps the registrations in ~/.melaya-runner/vaults/registry.json (0600) * and re-opens them on start (incremental re-index); * - indexes and serves them through a long-lived Python host (localVault.py, * the pure-Python engine from the shared bundle), index outside the vault * at ~/.melaya-runner/vaults//index.sqlite; * - verifies every vault capability ITSELF (Ed25519, keys pushed with * runner:vaultKeys): signature, exp, aud, grant selection, read/write, * route policy, pushed revocations, folder bounds. The server relay is * not trusted to bypass any of it; * - answers server requests on the socket (runner:vault-*) and local agent * requests on the local relay (POST /vaults/op), forwarding every vault it * does not hold to {server}/api/v1/vault-api/op. * * Never logged: capability tokens, note content, op arguments. */ import { type KeyObject } from "crypto"; import type { IncomingMessage, ServerResponse } from "http"; import { type ObsidianAppBridge } from "./obsidianApp.js"; import { VaultMirrorManager } from "./vaultMirror.js"; export declare const VAULT_HOST_CAPABILITY = "vault_host_v1"; /** Engine methods that change the vault. Everything else is a read. A write * needs "write" in the grant's ops; an unknown name stays a read here and the * Python host still refuses names that are not public engine methods. The * host applies the engine's own proposal in the same request (localVault.py * cmd_op), so the list must match its WRITE_OPS. */ export declare const VAULT_WRITE_OPS: ReadonlySet; /** Engine methods no caller may name: `apply` / `resume_recovery` take a * caller-supplied proposal (forged bytes), the rest are host lifecycle. */ export declare const VAULT_DENIED_OPS: ReadonlySet; export type VaultErrorCode = "not_selected" | "forbidden" | "forbidden_folder" | "vault_offline" | "app_offline" | "stale" | "not_found" | "ambiguous" | "conflict" | "unsupported" | "quota" | "policy_route" | "cursor_expired" | "invalid"; export interface VaultEnvelopeError { ok: false; error: { code: VaultErrorCode; message: string; retry_after: number | null; }; } export type VaultEnvelope = VaultEnvelopeError | ({ ok: true; } & Record); export declare function vaultErr(code: VaultErrorCode, message: string): VaultEnvelopeError; export interface CapGrant { vault_id: string; grant_id: string; ops: Array<"read" | "write">; folders: string[]; policy_rev: number; revocation_version: number; source: "device" | "upload" | "git" | "sync"; rnr_id: string | null; storage: "device_only" | "mirror"; relay: boolean; external_models: boolean; history: "reference" | "retain"; label: string; default: boolean; /** Vault policy "Developer tools" (obsidian_eval, dev:*): off unless true. */ dev_tools?: boolean; } export interface CapPayload { v: 1; kid: string; jti: string; sub: string; act: string; aud: string[]; iat: number; exp: number; route: "cloud" | "device"; models: Array<"external" | "local">; grants: CapGrant[]; } export interface VaultRegistration { id: string; label: string; path: string; configDirName: string; include: string[]; exclude: string[]; } /** Where a request came from: the server relay (runner:vault-op) or a local * agent through the 127.0.0.1 relay. Route policy differs (relay=true). */ export type VaultVia = "server" | "local"; /** kid → Ed25519 public key, from runner:vaultKeys {keys:[{kid, publicKeyRawB64}]}. */ export declare function parseVaultKeys(keys: unknown): Map; /** Decode the payload WITHOUT verifying it. Only for routing a local relay * request (local vs forward); every served request is fully verified. */ export declare function decodeVaultCapUnverified(token: string): CapPayload | null; export type CapCheck = { ok: true; payload: CapPayload; } | { ok: false; code: VaultErrorCode; reason: string; }; /** Record a run or turn capability received from the server for a local agent. */ export declare function trustRunVaultCapability(token: string | null | undefined): void; export declare function isTrustedRunVaultCapability(token: string): boolean; /** Signature, version, kid, lifetime and audience. `audiences` is any-of. `maxLifetimeS` is raised only for * run/turn capabilities the server handed to this runner (see above). */ export declare function verifyVaultCapability(token: string, keys: Map, audiences: string[], nowSec?: number, maxLifetimeS?: number): CapCheck; export type GrantPick = { ok: true; grant: CapGrant; } | { ok: false; error: VaultEnvelopeError; }; /** `vault` = a vlt_ id in grants, or the exact case-insensitive label of exactly * one grant. Empty = the grant marked default, or the only grant. */ export declare function selectVaultGrant(payload: Pick, vault: string | null | undefined): GrantPick; export declare function isVaultWriteOp(op: string): boolean; /** vaultId+grantId → the minimum revocation_version still valid. A grant whose * capability carries an older version fails the next call, whatever the * token's remaining lifetime. Process memory only: a restarted runner relies * on the short capability lifetime plus the server re-pushing. */ export declare class VaultRevocations { private readonly min; revoke(vaultId: string, grantId: string, revocationVersion: number): void; isRevoked(g: Pick): boolean; } /** Steps 2-5 of the enforcement order for one already-verified capability. * Returns the grant to serve, or the error envelope. */ export declare function authorizeVaultOp(payload: CapPayload, input: { vault: string | null | undefined; op: string; }, ctx: { via: VaultVia; revocations: VaultRevocations; }): { ok: true; grant: CapGrant; } | { ok: false; error: VaultEnvelopeError; }; export type FolderPolicyError = "forbidden_path" | "not_a_folder"; export interface FolderPolicyOpts { platform?: NodeJS.Platform; home?: string; runnerDir?: string; /** Extra absolute folders refused with all of their contents. */ extraForbidden?: string[]; } /** True iff `child` is `parent` or inside it. */ export declare function isInsidePath(platform: NodeJS.Platform, child: string, parent: string): boolean; /** Pure check of an already-resolved real path. */ export declare function checkVaultFolderPolicy(real: string, opts?: FolderPolicyOpts): FolderPolicyError | null; /** Resolve a picked folder to the real path the engine will serve, refusing * symlink / junction roots, non-directories and protected locations. */ export declare function resolveVaultFolder(input: string, opts?: FolderPolicyOpts): { ok: true; real: string; } | { ok: false; error: FolderPolicyError; }; export declare function defaultVaultsDir(): string; /** Validate a registration coming from the server. Path policy is separate. */ export declare function normalizeRegistration(v: unknown): VaultRegistration | null; export declare class VaultRegistry { readonly dir: string; readonly file: string; private vaults; constructor(dir?: string); load(): void; list(): VaultRegistration[]; get(id: string): VaultRegistration | undefined; upsert(reg: VaultRegistration): void; remove(id: string): boolean; /** Index folder of one vault (outside the vault). */ indexDir(id: string): string; private save; } export interface VaultHostStatusEvent { vaultId: string; status: string; notes?: number; bytes?: number; indexRev?: number; error?: string; capabilities?: unknown; } export interface VaultHostTransport { request(cmd: string, payload: Record, timeoutMs?: number): Promise; onEvent(cb: (ev: VaultHostStatusEvent) => void): void; /** Called after the host process (re)started, to re-register vaults. */ onRestart(cb: () => void): void; stop(): void; } export interface PythonLaunch { pythonPath: string; scriptPath: string; env: Record; cwd: string; } /** JSON-lines RPC to one long-lived `python -u localVault.py`, started lazily * and restarted with backoff when it dies while vaults are registered. */ export declare class VaultHostProcess implements VaultHostTransport { private readonly o; private proc; private starting; private seq; private buf; private readonly pending; private readonly eventCbs; private readonly restartCbs; private backoffMs; private stopped; private spawns; private restartTimer; readonly stderrTail: string[]; constructor(o: { launch: () => Promise; /** Whether a crashed host should be restarted (vaults still registered). */ wanted: () => boolean; log?: (m: string) => void; }); onEvent(cb: (ev: VaultHostStatusEvent) => void): void; onRestart(cb: () => void): void; private ensure; private onStdout; private onExit; request(cmd: string, payload: Record, timeoutMs?: number): Promise; stop(): void; } export type VaultResult = { ok: true; data?: unknown; envelope?: unknown; } | { ok: false; error: string; }; export interface VaultServiceOpts { rnrId: string; registry: VaultRegistry; host: VaultHostTransport; /** http(s) base of the Melaya server (for forwarding non-local vaults). */ serverHttpBase: string; emitStatus?: (ev: VaultHostStatusEvent) => void; log?: (m: string) => void; fetchImpl?: typeof fetch; folderPolicy?: FolderPolicyOpts; /** fs.watch the registered folders (off in tests). */ watch?: boolean; /** The Obsidian app bridge (obsidianApp.ts): App ops through the user's * running Obsidian. Absent = App ops are unsupported on this runner. */ app?: ObsidianAppBridge; } export declare class VaultService { private readonly o; private keys; readonly revocations: VaultRevocations; private readonly cursorKey; private readonly watchers; private readonly lastStatus; /** Cloud copy of `storage: mirror` vaults (vaultMirror.ts, plan §6.4 S4). */ readonly mirror: VaultMirrorManager; private mirrorSink; constructor(o: VaultServiceOpts); /** Where mirror renew requests and statuses go (the runner socket). */ setMirrorSink(sink: (event: string, payload: Record) => void): void; get rnrId(): string; hasKeys(): boolean; /** runner:vaultKeys — replaces the key set (the server sends all current kids). */ setKeys(keys: unknown): void; /** runner:vaultRevoke */ revoke(p: { vaultId?: unknown; grantId?: unknown; revocationVersion?: unknown; }): void; /** Statuses to re-send after a reconnect. */ latestStatuses(): VaultHostStatusEvent[]; private hostRegisterPayload; /** runner:vault-register */ register(input: unknown): Promise; /** runner:vault-unregister — also deletes the derived index (it holds note text). */ unregister(vaultId: unknown): Promise; /** Re-open every registered vault (startup, host restart). */ restore(): Promise; /** Verify a capability and serve one op for a vault held on THIS runner. */ op(input: { capability?: unknown; op?: unknown; vault?: unknown; args?: unknown; idempotency_key?: unknown; }, via: VaultVia): Promise; /** The vault's history policy travels with each answer (as the builder does): * obsidian_* tools keep excerpts out of saved logs unless it is "retain". */ private withHistory; /** One engine op on the Python host (re-registers once after a host restart). */ private hostOp; /** Merge the Obsidian app's availability into a vault's capability report * (stored by the server as agents.vaults.capabilities). */ private withAppCapabilities; /** runner:vault-app — the owner's vault settings asking about / connecting * the Obsidian app on this computer. The Local REST API key is read from * the plugin's own settings HERE and stays here: only states and a short * certificate fingerprint go back. */ appControl(p: { vaultId?: unknown; action?: unknown; }): Promise; /** True iff a relay request targets a device vault held by this runner. */ isLocalTarget(capability: string, vault: unknown): boolean; /** POST /vaults/op on the local relay (127.0.0.1). */ handleRelayHttp(req: IncomingMessage, res: ServerResponse): Promise; private startWatch; private stopWatch; stop(): void; } /** The subset of a socket.io-client Socket used here (a fake in tests). */ export interface VaultSocketLike { on(event: string, cb: (...args: any[]) => void): unknown; emit(event: string, ...args: any[]): unknown; } /** Wire the server → runner vault events. Every request is answered with * runner:vault-result {requestId, ok, data | envelope | error}. */ export declare function attachVaultSocketHandlers(socket: VaultSocketLike, service: VaultService, pickFolder: () => Promise<{ path: string; } | { cancelled: true; }>): void; /** Called once the local relay is listening (connection.ts). */ export declare function setVaultRelayPort(port: number): void; /** * Env a runner-hosted agent needs to call vault tools (protocol §5): the * capability and the LOCAL relay URL, which serves this computer's vaults * in-process and forwards every other vault to the server. * * Must be merged LAST into the subprocess env (after payload.credentials, so a * credential named MEL_VAULT_* can never replace it) by: * - the pipeline spawn in connection.ts `runner:run` (the env object built * next to `PYTHONPATH: sharedDir` / MEL_STUDIO_URL), from the run payload's * capability; * - the assistant host, per turn, in `runner:assistant_turn` (the turn's * capability; the host is long-lived, so the value rides the turn frame, * not the boot env built by `buildAssistantEnv`). * Returns {} when there is no capability or the relay is not up yet. */ export declare function vaultToolEnv(capability: string | null | undefined, relayPort?: number): Record; /** Locate localVault.py next to the compiled module (dist/) or one level up (src tree). */ export declare function findVaultHostScript(baseDir: string): string | null;