/** * Obsidian app bridge — the "Runs on: App" tools of * docs/plans/obsidian-vault-connector.md §7.1 (§6.3 S1 "App bridge"). * * Some Obsidian features only exist in the running app: file recovery * (history, diff), Sync, Publish, the command palette, plugins, themes, * snippets, workspaces and tabs, the web viewer, developer tools. For a vault * registered on THIS computer the runner reaches the user's own Obsidian * through one of two backends: * * - the official Obsidian CLI (1.12.7+, Settings → General → Command line * interface), detected automatically. Always spawned with an ARGUMENT * ARRAY (never a shell string), `vault=` first, a deadline and an output cap; * - optionally the Local REST API plugin (5.4.0). Its URL and API key are * stored on this computer only (~/.melaya-runner/vaults/obsidian-app.json, * 0600) and never sent to Melaya, logs or models. The plugin serves a * self-signed certificate: its SHA-256 fingerprint is pinned at connect * time and every request checks it BEFORE any header (the key) is * written. TLS verification is never disabled globally. * * Endpoints come from the user (the plugin's own settings in the vault, or * `melaya-runner obsidian-rest`), never from a model's arguments. * * Authority (capability, grant, read/write, route, revocation, dev_tools) is * checked by vaults.ts before an op reaches this module; the bridge re-checks * dev_tools and folder bounds. App ops act on the whole vault, so a grant * limited to folders cannot use them (Files+App reads then stay on the files * engine, which filters by folder). * * Never logged: the API key, op arguments, app output. */ import { type ChildProcess, type SpawnOptions } from "child_process"; import tls from "tls"; type ParamKind = "str" | "int" | "flag" | "json" | "code" | "url" | "vpath" | readonly string[]; /** How an op is served. * app only the running app has it (`app_*` ops). * prefer Files + App: the app when reachable, else the files engine. * untargeted files engine when the call names a note; the app's active note otherwise. * fallback files engine first; the app when the engine says unsupported. */ type Mode = "app" | "prefer" | "untargeted" | "fallback"; type RestRoute = "version" | "active" | "dataview" | "commands" | "command" | "open"; export interface AppOpSpec { cmd: string | null; write: boolean; dev?: boolean; mode?: Mode; params?: Record; /** A bare `on|off` positional (`sync on`, `dev:debug off`). */ state?: "required" | "optional"; required?: string[]; /** No `vault=` prefix (app-wide commands). */ noVault?: boolean; /** Append `format=json` unless the caller chose a format. */ json?: boolean; timeoutMs?: number; rest?: RestRoute; /** Args the files engine accepts for a prefer/fallback op (the rest are stripped on fallback). */ filesArgs?: readonly string[]; /** Files+App op without a files implementation. */ noFiles?: boolean; } export declare const APP_OPS: Readonly>; /** App ops that change something (need "write" in the grant; HITL at the tool). */ export declare const APP_WRITE_OPS: ReadonlySet; /** Developer ops: only when the vault owner turned on "Developer tools". */ export declare const APP_DEV_OPS: ReadonlySet; /** Ops that only the running app serves (app_* plus Files+App ops without a files implementation). */ export declare const APP_ONLY_OPS: ReadonlySet; export declare function isAppOp(op: string): boolean; export type AppErrorCode = "app_offline" | "unsupported" | "forbidden" | "forbidden_folder" | "invalid" | "not_found"; export declare class AppBridgeError extends Error { readonly code: AppErrorCode; constructor(code: AppErrorCode, message: string); } export declare function appErr(code: AppErrorCode, message: string): { ok: false; error: { code: AppErrorCode; message: string; retry_after: null; }; }; export type AppBackend = "cli" | "rest"; export declare function appOk(vaultId: string, value: unknown, o: { backend: AppBackend; truncated?: boolean; status?: string; extra?: Record; }): { ok: true; } & Record; /** * Build the argv for one CLI call: [`vault=`], , key=value…, * flags, an optional on|off positional. Each value is ONE argv element, so no * value can add a parameter, a flag or a shell construct. Unknown arguments * are refused (the model sees which). */ export declare function buildCliArgs(op: string, args: Record, vaultRef: string | null): { ok: true; argv: string[]; } | { ok: false; message: string; }; export declare const CLI_DEFAULT_TIMEOUT_MS = 30000; export declare const CLI_MAX_OUTPUT_BYTES: number; export interface CliRunResult { code: number | null; stdout: string; stderr: string; timedOut: boolean; truncated: boolean; } export type SpawnFn = (cmd: string, args: string[], opts: SpawnOptions) => ChildProcess; /** Run the CLI once: argv array, no shell, deadline, output cap (the process * is killed past either). Never rejects. */ export declare function runCli(bin: string, argv: string[], o?: { timeoutMs?: number; maxBytes?: number; spawnImpl?: SpawnFn; cwd?: string; }): Promise; /** Map one CLI result to a value or a typed error. The CLI prints errors on * stdout with exit code 0 ("Error: …"), so the text is checked too. */ export declare function interpretCliOutput(r: CliRunResult, timeoutMs?: number): { ok: true; value: unknown; truncated: boolean; } | { ok: false; code: AppErrorCode; message: string; }; export interface HostEnv { platform: NodeJS.Platform; env: NodeJS.ProcessEnv; home: string; } /** Where the official installer registers the CLI (help: "Command line * interface"). MELAYA_OBSIDIAN_CLI overrides it for custom installs; it is * local configuration of this computer, never a model argument. */ export declare function cliCandidates(h: HostEnv): string[]; /** A launchable CLI binary. Batch files are refused: Windows runs them * through cmd.exe, where an argument array is no longer safe. */ export declare function findCli(h: HostEnv, exists?: (p: string) => boolean): string | null; /** obsidian.json: the app's own list of vaults ({vaults: {id: {path, open}}}). */ export declare function obsidianConfigCandidates(h: HostEnv): string[]; /** The app's id for a vault folder (what `vault=` takes), or null. */ export declare function findObsidianVaultId(config: unknown, vaultPath: string, platform: NodeJS.Platform): { id: string; open: boolean; } | null; export type RunningProbe = () => Promise; /** Is the Obsidian desktop app running? null = could not tell. Checked first * so a read never launches the app as a side effect. */ export declare function defaultRunningProbe(platform: NodeJS.Platform, spawnImpl?: SpawnFn): RunningProbe; export declare const REST_PLUGIN_ID = "obsidian-local-rest-api"; export interface RestConfig { url: string; apiKey: string; fingerprint256: string; headerName?: string; connectedAt?: string; } /** Upper-case hex without separators. */ export declare function normalizeFingerprint(fp: string): string; export declare function fingerprintsEqual(a: string, b: string): boolean; /** https on a loopback host only: the bridge never talks to another machine. */ export declare function parseRestUrl(u: string): { host: string; port: number; } | null; export interface PinnedResponse { status: number; contentType: string; body: Buffer; } export type TlsConnectFn = (opts: tls.ConnectionOptions) => tls.TLSSocket; /** * One HTTPS request to the plugin with the certificate pinned. The TLS * session is opened first; the peer certificate's SHA-256 fingerprint must * equal the pinned one before the request (and its Authorization header) is * written. A mismatch is a `forbidden` refusal, never a silent retry. */ export declare function pinnedRequest(cfg: Pick, req: { method: string; path: string; headers?: Record; body?: string; timeoutMs?: number; maxBytes?: number; }, connectImpl?: TlsConnectFn): Promise; /** SHA-256 of the certificate the endpoint presents (trust-on-first-use for * a manual connect; the user confirms it). Sends nothing but a TLS hello. */ export declare function peerFingerprint(url: string, connectImpl?: TlsConnectFn, timeoutMs?: number): Promise; export interface RestPluginSettings { port: number; apiKey: string; certPem: string; fingerprint256: string; headerName: string; } /** Read the plugin's own settings from the vault's configuration folder * (`/plugins/obsidian-local-rest-api/data.json`): the port, the API * key and the certificate it serves, so the fingerprint is pinned from the * plugin itself rather than trusted on first use. Local read; nothing leaves * this computer. */ export declare function readRestPluginSettings(vaultRoot: string, configDirName: string): { ok: true; settings: RestPluginSettings; } | { ok: false; error: string; }; export declare class AppLocalConfig { readonly dir: string; readonly file: string; private cache; constructor(dir: string); private load; getRest(vaultId: string): RestConfig | null; setRest(vaultId: string, cfg: RestConfig | null): void; } /** What the bridge needs of a registered vault. */ export interface AppVaultRef { id: string; label: string; path: string; configDirName: string; } /** The grant fields the bridge re-checks. */ export interface AppGrantRef { ops: string[]; folders: string[]; dev_tools?: boolean; } export type CliState = "ready" | "not_installed" | "not_running" | "cli_disabled" | "vault_unknown"; export type RestState = "connected" | "not_configured" | "unreachable" | "cert_changed" | "key_rejected"; export interface AppStatus { cli: CliState; rest: RestState; version: string | null; /** Capability names for the vault report: app_cli, app_rest. */ features: string[]; /** Short fingerprint of the pinned certificate (display only). */ restFingerprint: string | null; } export type ServeResult = { kind: "env"; env: Record; } | { kind: "files"; args: Record; }; export interface ObsidianAppBridgeOpts { /** Folder for obsidian-app.json (the vault registry's folder). */ dir: string; log?: (m: string) => void; host?: HostEnv; spawnImpl?: SpawnFn; runningProbe?: RunningProbe; tlsConnect?: TlsConnectFn; fileExists?: (p: string) => boolean; /** Override where obsidian.json is read from (tests). */ readObsidianConfig?: () => unknown; } export declare class ObsidianAppBridge { private readonly o; readonly config: AppLocalConfig; private readonly h; private readonly runningProbe; private running; private versionProbe; private readonly applied; private readonly writeChains; constructor(o: ObsidianAppBridgeOpts); private bin; private obsidianConfig; private isRunning; /** CLI state for one vault + the vault= reference. */ private cliState; private restRequest; private restState; /** Live App availability for one vault (UI status row, capability report). */ status(reg: AppVaultRef): Promise; /** Connect the Local REST API of this vault from the plugin's own settings. */ connectRest(reg: AppVaultRef): Promise<{ ok: true; status: AppStatus; } | { ok: false; error: string; }>; disconnectRest(vaultId: string): void; /** Forget cached probes (after the user changed something). */ invalidate(): void; /** * Serve an op through the app, or say it belongs to the files engine. * Returns null for ops the bridge does not know. `grant` is the already * verified grant (vaults.ts authorizeVaultOp). */ serve(input: { reg: AppVaultRef; grant: AppGrantRef; op: string; args: Record; idempotencyKey?: string | null; }): Promise; /** base_query: the files engine answered `unsupported`; the live app may serve it. */ afterFiles(input: { reg: AppVaultRef; grant: AppGrantRef; op: string; args: Record; }, filesEnv: unknown): Promise | null>; /** One write at a time per vault; a retried idempotency key returns the stored result. */ private applyOnce; private runViaCli; private screenshot; private runViaRest; } export declare function offlineReason(cli: CliState | null, restPossible: boolean): string; export {};