@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.
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Obsidian app bridge — the "Runs on: App" tools of
3
+ * docs/plans/obsidian-vault-connector.md §7.1 (§6.3 S1 "App bridge").
4
+ *
5
+ * Some Obsidian features only exist in the running app: file recovery
6
+ * (history, diff), Sync, Publish, the command palette, plugins, themes,
7
+ * snippets, workspaces and tabs, the web viewer, developer tools. For a vault
8
+ * registered on THIS computer the runner reaches the user's own Obsidian
9
+ * through one of two backends:
10
+ *
11
+ * - the official Obsidian CLI (1.12.7+, Settings → General → Command line
12
+ * interface), detected automatically. Always spawned with an ARGUMENT
13
+ * ARRAY (never a shell string), `vault=<the app's id for the registered
14
+ * folder>` first, a deadline and an output cap;
15
+ * - optionally the Local REST API plugin (5.4.0). Its URL and API key are
16
+ * stored on this computer only (~/.melaya-runner/vaults/obsidian-app.json,
17
+ * 0600) and never sent to Melaya, logs or models. The plugin serves a
18
+ * self-signed certificate: its SHA-256 fingerprint is pinned at connect
19
+ * time and every request checks it BEFORE any header (the key) is
20
+ * written. TLS verification is never disabled globally.
21
+ *
22
+ * Endpoints come from the user (the plugin's own settings in the vault, or
23
+ * `melaya-runner obsidian-rest`), never from a model's arguments.
24
+ *
25
+ * Authority (capability, grant, read/write, route, revocation, dev_tools) is
26
+ * checked by vaults.ts before an op reaches this module; the bridge re-checks
27
+ * dev_tools and folder bounds. App ops act on the whole vault, so a grant
28
+ * limited to folders cannot use them (Files+App reads then stay on the files
29
+ * engine, which filters by folder).
30
+ *
31
+ * Never logged: the API key, op arguments, app output.
32
+ */
33
+ import { type ChildProcess, type SpawnOptions } from "child_process";
34
+ import tls from "tls";
35
+ type ParamKind = "str" | "int" | "flag" | "json" | "code" | "url" | "vpath" | readonly string[];
36
+ /** How an op is served.
37
+ * app only the running app has it (`app_*` ops).
38
+ * prefer Files + App: the app when reachable, else the files engine.
39
+ * untargeted files engine when the call names a note; the app's active note otherwise.
40
+ * fallback files engine first; the app when the engine says unsupported. */
41
+ type Mode = "app" | "prefer" | "untargeted" | "fallback";
42
+ type RestRoute = "version" | "active" | "dataview" | "commands" | "command" | "open";
43
+ export interface AppOpSpec {
44
+ cmd: string | null;
45
+ write: boolean;
46
+ dev?: boolean;
47
+ mode?: Mode;
48
+ params?: Record<string, ParamKind>;
49
+ /** A bare `on|off` positional (`sync on`, `dev:debug off`). */
50
+ state?: "required" | "optional";
51
+ required?: string[];
52
+ /** No `vault=` prefix (app-wide commands). */
53
+ noVault?: boolean;
54
+ /** Append `format=json` unless the caller chose a format. */
55
+ json?: boolean;
56
+ timeoutMs?: number;
57
+ rest?: RestRoute;
58
+ /** Args the files engine accepts for a prefer/fallback op (the rest are stripped on fallback). */
59
+ filesArgs?: readonly string[];
60
+ /** Files+App op without a files implementation. */
61
+ noFiles?: boolean;
62
+ }
63
+ export declare const APP_OPS: Readonly<Record<string, AppOpSpec>>;
64
+ /** App ops that change something (need "write" in the grant; HITL at the tool). */
65
+ export declare const APP_WRITE_OPS: ReadonlySet<string>;
66
+ /** Developer ops: only when the vault owner turned on "Developer tools". */
67
+ export declare const APP_DEV_OPS: ReadonlySet<string>;
68
+ /** Ops that only the running app serves (app_* plus Files+App ops without a files implementation). */
69
+ export declare const APP_ONLY_OPS: ReadonlySet<string>;
70
+ export declare function isAppOp(op: string): boolean;
71
+ export type AppErrorCode = "app_offline" | "unsupported" | "forbidden" | "forbidden_folder" | "invalid" | "not_found";
72
+ export declare class AppBridgeError extends Error {
73
+ readonly code: AppErrorCode;
74
+ constructor(code: AppErrorCode, message: string);
75
+ }
76
+ export declare function appErr(code: AppErrorCode, message: string): {
77
+ ok: false;
78
+ error: {
79
+ code: AppErrorCode;
80
+ message: string;
81
+ retry_after: null;
82
+ };
83
+ };
84
+ export type AppBackend = "cli" | "rest";
85
+ export declare function appOk(vaultId: string, value: unknown, o: {
86
+ backend: AppBackend;
87
+ truncated?: boolean;
88
+ status?: string;
89
+ extra?: Record<string, unknown>;
90
+ }): {
91
+ ok: true;
92
+ } & Record<string, unknown>;
93
+ /**
94
+ * Build the argv for one CLI call: [`vault=<ref>`], <command>, key=value…,
95
+ * flags, an optional on|off positional. Each value is ONE argv element, so no
96
+ * value can add a parameter, a flag or a shell construct. Unknown arguments
97
+ * are refused (the model sees which).
98
+ */
99
+ export declare function buildCliArgs(op: string, args: Record<string, unknown>, vaultRef: string | null): {
100
+ ok: true;
101
+ argv: string[];
102
+ } | {
103
+ ok: false;
104
+ message: string;
105
+ };
106
+ export declare const CLI_DEFAULT_TIMEOUT_MS = 30000;
107
+ export declare const CLI_MAX_OUTPUT_BYTES: number;
108
+ export interface CliRunResult {
109
+ code: number | null;
110
+ stdout: string;
111
+ stderr: string;
112
+ timedOut: boolean;
113
+ truncated: boolean;
114
+ }
115
+ export type SpawnFn = (cmd: string, args: string[], opts: SpawnOptions) => ChildProcess;
116
+ /** Run the CLI once: argv array, no shell, deadline, output cap (the process
117
+ * is killed past either). Never rejects. */
118
+ export declare function runCli(bin: string, argv: string[], o?: {
119
+ timeoutMs?: number;
120
+ maxBytes?: number;
121
+ spawnImpl?: SpawnFn;
122
+ cwd?: string;
123
+ }): Promise<CliRunResult>;
124
+ /** Map one CLI result to a value or a typed error. The CLI prints errors on
125
+ * stdout with exit code 0 ("Error: …"), so the text is checked too. */
126
+ export declare function interpretCliOutput(r: CliRunResult, timeoutMs?: number): {
127
+ ok: true;
128
+ value: unknown;
129
+ truncated: boolean;
130
+ } | {
131
+ ok: false;
132
+ code: AppErrorCode;
133
+ message: string;
134
+ };
135
+ export interface HostEnv {
136
+ platform: NodeJS.Platform;
137
+ env: NodeJS.ProcessEnv;
138
+ home: string;
139
+ }
140
+ /** Where the official installer registers the CLI (help: "Command line
141
+ * interface"). MELAYA_OBSIDIAN_CLI overrides it for custom installs; it is
142
+ * local configuration of this computer, never a model argument. */
143
+ export declare function cliCandidates(h: HostEnv): string[];
144
+ /** A launchable CLI binary. Batch files are refused: Windows runs them
145
+ * through cmd.exe, where an argument array is no longer safe. */
146
+ export declare function findCli(h: HostEnv, exists?: (p: string) => boolean): string | null;
147
+ /** obsidian.json: the app's own list of vaults ({vaults: {id: {path, open}}}). */
148
+ export declare function obsidianConfigCandidates(h: HostEnv): string[];
149
+ /** The app's id for a vault folder (what `vault=` takes), or null. */
150
+ export declare function findObsidianVaultId(config: unknown, vaultPath: string, platform: NodeJS.Platform): {
151
+ id: string;
152
+ open: boolean;
153
+ } | null;
154
+ export type RunningProbe = () => Promise<boolean | null>;
155
+ /** Is the Obsidian desktop app running? null = could not tell. Checked first
156
+ * so a read never launches the app as a side effect. */
157
+ export declare function defaultRunningProbe(platform: NodeJS.Platform, spawnImpl?: SpawnFn): RunningProbe;
158
+ export declare const REST_PLUGIN_ID = "obsidian-local-rest-api";
159
+ export interface RestConfig {
160
+ url: string;
161
+ apiKey: string;
162
+ fingerprint256: string;
163
+ headerName?: string;
164
+ connectedAt?: string;
165
+ }
166
+ /** Upper-case hex without separators. */
167
+ export declare function normalizeFingerprint(fp: string): string;
168
+ export declare function fingerprintsEqual(a: string, b: string): boolean;
169
+ /** https on a loopback host only: the bridge never talks to another machine. */
170
+ export declare function parseRestUrl(u: string): {
171
+ host: string;
172
+ port: number;
173
+ } | null;
174
+ export interface PinnedResponse {
175
+ status: number;
176
+ contentType: string;
177
+ body: Buffer;
178
+ }
179
+ export type TlsConnectFn = (opts: tls.ConnectionOptions) => tls.TLSSocket;
180
+ /**
181
+ * One HTTPS request to the plugin with the certificate pinned. The TLS
182
+ * session is opened first; the peer certificate's SHA-256 fingerprint must
183
+ * equal the pinned one before the request (and its Authorization header) is
184
+ * written. A mismatch is a `forbidden` refusal, never a silent retry.
185
+ */
186
+ export declare function pinnedRequest(cfg: Pick<RestConfig, "url" | "fingerprint256">, req: {
187
+ method: string;
188
+ path: string;
189
+ headers?: Record<string, string>;
190
+ body?: string;
191
+ timeoutMs?: number;
192
+ maxBytes?: number;
193
+ }, connectImpl?: TlsConnectFn): Promise<PinnedResponse>;
194
+ /** SHA-256 of the certificate the endpoint presents (trust-on-first-use for
195
+ * a manual connect; the user confirms it). Sends nothing but a TLS hello. */
196
+ export declare function peerFingerprint(url: string, connectImpl?: TlsConnectFn, timeoutMs?: number): Promise<string>;
197
+ export interface RestPluginSettings {
198
+ port: number;
199
+ apiKey: string;
200
+ certPem: string;
201
+ fingerprint256: string;
202
+ headerName: string;
203
+ }
204
+ /** Read the plugin's own settings from the vault's configuration folder
205
+ * (`<config>/plugins/obsidian-local-rest-api/data.json`): the port, the API
206
+ * key and the certificate it serves, so the fingerprint is pinned from the
207
+ * plugin itself rather than trusted on first use. Local read; nothing leaves
208
+ * this computer. */
209
+ export declare function readRestPluginSettings(vaultRoot: string, configDirName: string): {
210
+ ok: true;
211
+ settings: RestPluginSettings;
212
+ } | {
213
+ ok: false;
214
+ error: string;
215
+ };
216
+ export declare class AppLocalConfig {
217
+ readonly dir: string;
218
+ readonly file: string;
219
+ private cache;
220
+ constructor(dir: string);
221
+ private load;
222
+ getRest(vaultId: string): RestConfig | null;
223
+ setRest(vaultId: string, cfg: RestConfig | null): void;
224
+ }
225
+ /** What the bridge needs of a registered vault. */
226
+ export interface AppVaultRef {
227
+ id: string;
228
+ label: string;
229
+ path: string;
230
+ configDirName: string;
231
+ }
232
+ /** The grant fields the bridge re-checks. */
233
+ export interface AppGrantRef {
234
+ ops: string[];
235
+ folders: string[];
236
+ dev_tools?: boolean;
237
+ }
238
+ export type CliState = "ready" | "not_installed" | "not_running" | "cli_disabled" | "vault_unknown";
239
+ export type RestState = "connected" | "not_configured" | "unreachable" | "cert_changed" | "key_rejected";
240
+ export interface AppStatus {
241
+ cli: CliState;
242
+ rest: RestState;
243
+ version: string | null;
244
+ /** Capability names for the vault report: app_cli, app_rest. */
245
+ features: string[];
246
+ /** Short fingerprint of the pinned certificate (display only). */
247
+ restFingerprint: string | null;
248
+ }
249
+ export type ServeResult = {
250
+ kind: "env";
251
+ env: Record<string, unknown>;
252
+ } | {
253
+ kind: "files";
254
+ args: Record<string, unknown>;
255
+ };
256
+ export interface ObsidianAppBridgeOpts {
257
+ /** Folder for obsidian-app.json (the vault registry's folder). */
258
+ dir: string;
259
+ log?: (m: string) => void;
260
+ host?: HostEnv;
261
+ spawnImpl?: SpawnFn;
262
+ runningProbe?: RunningProbe;
263
+ tlsConnect?: TlsConnectFn;
264
+ fileExists?: (p: string) => boolean;
265
+ /** Override where obsidian.json is read from (tests). */
266
+ readObsidianConfig?: () => unknown;
267
+ }
268
+ export declare class ObsidianAppBridge {
269
+ private readonly o;
270
+ readonly config: AppLocalConfig;
271
+ private readonly h;
272
+ private readonly runningProbe;
273
+ private running;
274
+ private versionProbe;
275
+ private readonly applied;
276
+ private readonly writeChains;
277
+ constructor(o: ObsidianAppBridgeOpts);
278
+ private bin;
279
+ private obsidianConfig;
280
+ private isRunning;
281
+ /** CLI state for one vault + the vault= reference. */
282
+ private cliState;
283
+ private restRequest;
284
+ private restState;
285
+ /** Live App availability for one vault (UI status row, capability report). */
286
+ status(reg: AppVaultRef): Promise<AppStatus>;
287
+ /** Connect the Local REST API of this vault from the plugin's own settings. */
288
+ connectRest(reg: AppVaultRef): Promise<{
289
+ ok: true;
290
+ status: AppStatus;
291
+ } | {
292
+ ok: false;
293
+ error: string;
294
+ }>;
295
+ disconnectRest(vaultId: string): void;
296
+ /** Forget cached probes (after the user changed something). */
297
+ invalidate(): void;
298
+ /**
299
+ * Serve an op through the app, or say it belongs to the files engine.
300
+ * Returns null for ops the bridge does not know. `grant` is the already
301
+ * verified grant (vaults.ts authorizeVaultOp).
302
+ */
303
+ serve(input: {
304
+ reg: AppVaultRef;
305
+ grant: AppGrantRef;
306
+ op: string;
307
+ args: Record<string, unknown>;
308
+ idempotencyKey?: string | null;
309
+ }): Promise<ServeResult | null>;
310
+ /** base_query: the files engine answered `unsupported`; the live app may serve it. */
311
+ afterFiles(input: {
312
+ reg: AppVaultRef;
313
+ grant: AppGrantRef;
314
+ op: string;
315
+ args: Record<string, unknown>;
316
+ }, filesEnv: unknown): Promise<Record<string, unknown> | null>;
317
+ /** One write at a time per vault; a retried idempotency key returns the stored result. */
318
+ private applyOnce;
319
+ private runViaCli;
320
+ private screenshot;
321
+ private runViaRest;
322
+ }
323
+ export declare function offlineReason(cli: CliState | null, restPossible: boolean): string;
324
+ export {};