feat: add Obsidian CLI API service
Build and Push Docker Container / build-and-push (push) Successful in 4m26s

- Add Docker image for the official Obsidian desktop app and CLI.

- Start Obsidian headlessly with Xvfb and DBus setup.

- Expose a safe structured HTTP command API without shell execution.

- Add JWT-based vault, path, and command authorization.

- Support single-vault and multi-vault container mounts.

- Add TypeScript SDK helpers for Obsidian CLI commands.

- Add n8n community node package with Obsidian operations.

- Add docs, compose config, tests, and production image workflow.
This commit is contained in:
2026-06-24 09:11:13 +02:00
commit 902a3df8b5
34 changed files with 5153 additions and 0 deletions
View File
+84
View File
@@ -0,0 +1,84 @@
from dataclasses import dataclass
import hashlib, base64, hmac
from typing import Any
import json, time
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.config import JWT_SECRET
from app.policy import TokenPolicy, tuple_claim
@dataclass(frozen=True)
class AuthContext:
policy:TokenPolicy|None
def error(status_code:int, detail:str) -> JSONResponse:
return JSONResponse({"detail": detail}, status_code=status_code)
def b64url_decode(value:str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode((value + padding).encode())
def decode_hs256_jwt(token:str, secret:bytes) -> dict[str, Any]:
parts = token.split(".")
if len(parts) != 3:
raise ValueError("JWT must have three parts")
header_b64, payload_b64, signature_b64 = parts
try:
header = json.loads(b64url_decode(header_b64))
payload = json.loads(b64url_decode(payload_b64))
except (json.JSONDecodeError, ValueError) as exc:
raise ValueError("JWT header or payload is invalid") from exc
if not isinstance(header, dict) or not isinstance(payload, dict):
raise ValueError("JWT header and payload must be JSON objects")
if header.get("alg") != "HS256":
raise ValueError("only HS256 JWTs are supported")
if header.get("typ") not in (None, "JWT"):
raise ValueError("JWT typ must be JWT")
signing_input = f"{header_b64}.{payload_b64}".encode()
expected = hmac.new(secret, signing_input, hashlib.sha256).digest()
supplied = b64url_decode(signature_b64)
if not hmac.compare_digest(supplied, expected):
raise ValueError("JWT signature is invalid")
now = int(time.time())
exp = payload.get("exp")
if exp is not None and (not isinstance(exp, int | float) or now >= int(exp)):
raise ValueError("JWT has expired")
nbf = payload.get("nbf")
if nbf is not None and (not isinstance(nbf, int | float) or now < int(nbf)):
raise ValueError("JWT is not active yet")
iat = payload.get("iat")
if iat is not None and (not isinstance(iat, int | float) or int(iat) > now + 300):
raise ValueError("JWT iat is in the future")
return payload
def policy_from_jwt_claims(token:str, claims:dict[str, Any]) -> TokenPolicy:
name = str(claims.get("name") or claims.get("sub") or "jwt")
return TokenPolicy(
token=token,
name=name,
vaults=tuple_claim(claims.get("vaults"), ("*",)),
paths=tuple_claim(claims.get("paths"), ("*",)),
commands=tuple_claim(claims.get("commands"), ("*",)),
)
def require_auth(request:Request) -> AuthContext|JSONResponse:
if not JWT_SECRET:
return error(500, "OBSIDIAN_JWT_SECRET is required")
header = request.headers.get("authorization", "")
if not header.startswith("Bearer "):
return error(401, "Unauthorized")
supplied = header.removeprefix("Bearer ")
try:
claims = decode_hs256_jwt(supplied, JWT_SECRET.encode())
return AuthContext(policy=policy_from_jwt_claims(supplied, claims))
except ValueError:
return error(401, "Unauthorized")
+37
View File
@@ -0,0 +1,37 @@
from pathlib import Path
import json, os
JWT_SECRET = os.getenv("OBSIDIAN_JWT_SECRET", "")
DEFAULT_TIMEOUT = float(os.getenv("COMMAND_TIMEOUT", "60"))
MAX_TIMEOUT = float(os.getenv("MAX_COMMAND_TIMEOUT", "300"))
CLI_BIN = os.getenv("OBSIDIAN_CLI_BIN", "obsidian")
VAULT_PATH = Path(os.getenv("OBSIDIAN_VAULT_PATH", "/vault")).resolve()
HOST = os.getenv("OBSIDIAN_API_HOST", "0.0.0.0")
PORT = int(os.getenv("OBSIDIAN_API_PORT", "8080"))
def parse_registered_vaults() -> dict[str, str]:
raw = os.getenv("OBSIDIAN_VAULTS", "").strip()
vaults:dict[str, str] = {}
if raw:
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return {str(name): str(path) for name, path in parsed.items()}
except json.JSONDecodeError:
pass
for chunk in raw.replace("\n", ",").split(","):
chunk = chunk.strip()
if not chunk or "=" not in chunk:
continue
name, path = chunk.split("=", 1)
vaults[name.strip()] = path.strip()
if not vaults:
vaults[VAULT_PATH.name or "vault"] = str(VAULT_PATH)
return vaults
REGISTERED_VAULTS = parse_registered_vaults()
DEFAULT_VAULT_NAME = next(iter(REGISTERED_VAULTS), VAULT_PATH.name or "vault")
+122
View File
@@ -0,0 +1,122 @@
from pathlib import Path
from typing import Any
import json
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from app import auth
from app.auth import require_auth
from app.config import CLI_BIN, DEFAULT_TIMEOUT, HOST, MAX_TIMEOUT, PORT, VAULT_PATH
from app.policy import authorize_command, vault_for_path
from app.runner import execute_command
def error(status_code:int, detail:str) -> JSONResponse:
return JSONResponse({"detail": detail}, status_code=status_code)
def parse_args(value:Any) -> list[str]:
if value is None:
return []
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
raise ValueError("args must be a list of strings")
return value
def parse_timeout(value:Any) -> float:
if value is None:
return DEFAULT_TIMEOUT
if not isinstance(value, int | float) or value <= 0:
raise ValueError("timeout must be a positive number")
return min(float(value), MAX_TIMEOUT)
def safe_cwd(cwd:str|None) -> Path:
path = Path(cwd).resolve() if cwd else VAULT_PATH
if not path.exists() or not path.is_dir():
raise ValueError(f"cwd does not exist or is not a directory: {path}")
if vault_for_path(path) is None:
raise ValueError(f"cwd must be inside a registered vault: {path}")
return path
async def health(request:Request) -> JSONResponse:
return JSONResponse(
{
"ok": True,
"vault": str(VAULT_PATH),
"cli": CLI_BIN,
"auth": bool(auth.JWT_SECRET),
"jwt": bool(auth.JWT_SECRET),
}
)
async def run_command(request:Request) -> JSONResponse:
auth_context = require_auth(request)
if isinstance(auth_context, JSONResponse):
return auth_context
try:
payload = await request.json()
except json.JSONDecodeError:
return error(400, "invalid JSON body")
try:
if "command" in payload:
raise ValueError("command strings are not allowed; use args as a list of strings")
if "mode" in payload:
raise ValueError("mode is not allowed; only the configured Obsidian CLI can be executed")
args = parse_args(payload.get("args"))
stdin = payload.get("stdin")
if stdin is not None and not isinstance(stdin, str):
raise ValueError("stdin must be a string")
cwd_value = payload.get("cwd")
if cwd_value is not None and not isinstance(cwd_value, str):
raise ValueError("cwd must be a string")
cwd = safe_cwd(cwd_value)
timeout = parse_timeout(payload.get("timeout"))
authorize_command(auth_context.policy, args, cwd)
except ValueError as exc:
return error(400, str(exc))
except PermissionError as exc:
return error(403, str(exc))
return await execute_command(args=args, cwd=cwd, stdin=stdin, timeout=timeout)
async def typescript_sdk_example(request:Request) -> JSONResponse:
return JSONResponse(
{
"example": """
import { ObsidianCliClient } from '@obsidian-api/sdk';
const obsidian = new ObsidianCliClient({
baseUrl: 'http://localhost:8080',
token: '<jwt>',
});
await obsidian.create({
path: 'Inbox/Hello.md',
content: '# Hello from n8n/Prism',
overwrite: true,
});
const note = await obsidian.read({ path: 'Inbox/Hello.md' });
console.log(note.stdout);
""".strip()
}
)
app = Starlette(
debug=False,
routes=[
Route("/health", health, methods=["GET"]),
Route("/commands", run_command, methods=["POST"]),
Route("/sdk/typescript", typescript_sdk_example, methods=["GET"]),
],
)
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host=HOST, port=PORT)
+150
View File
@@ -0,0 +1,150 @@
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.config import DEFAULT_VAULT_NAME, REGISTERED_VAULTS
@dataclass(frozen=True)
class TokenPolicy:
token:str = ""
name:str = "jwt"
vaults:tuple[str, ...] = ("*",)
paths:tuple[str, ...] = ("*",)
commands:tuple[str, ...] = ("*",)
def allows_all_vaults(self) -> bool:
return "*" in self.vaults
def allows_all_paths(self) -> bool:
return "*" in self.paths
def allows_all_commands(self) -> bool:
return "*" in self.commands
@dataclass(frozen=True)
class CommandTarget:
command:str|None
vault:str|None
paths:tuple[str, ...]
PATH_KEYS = {"path", "file", "folder", "name", "to"}
PATH_REQUIRED_WHEN_RESTRICTED = {
"append", "prepend", "read", "create", "delete", "rename", "move", "file", "folder", "open", "diff",
"search", "search:context", "files", "folders",
"property:set", "property:remove", "property:read", "properties",
"aliases", "backlinks", "links", "outlinks", "tags", "tasks", "task",
}
PATHLESS_ALLOWED_WHEN_RESTRICTED = {"help", "version"}
def tuple_claim(value:Any, default:tuple[str, ...]) -> tuple[str, ...]:
if value is None:
return default
if isinstance(value, str):
return (value,)
if isinstance(value, list) and all(isinstance(item, str) for item in value):
return tuple(value)
raise ValueError("JWT claims vaults, paths and commands must be strings or string arrays")
def vault_for_path(path:Path) -> str|None:
resolved = path.resolve()
best_name:str|None = None
best_len = -1
for name, vault_path in REGISTERED_VAULTS.items():
root = Path(vault_path).resolve()
try:
resolved.relative_to(root)
except ValueError:
continue
root_len = len(str(root))
if root_len > best_len:
best_name = name
best_len = root_len
return best_name
def normalize_policy_path(value:str) -> str:
normalized = value.strip().replace("\\", "/").lstrip("/")
while "//" in normalized:
normalized = normalized.replace("//", "/")
return normalized
def is_path_allowed(path:str, allowed_patterns:tuple[str, ...]) -> bool:
normalized = normalize_policy_path(path)
for pattern in allowed_patterns:
pattern_normalized = normalize_policy_path(pattern)
if pattern_normalized == "*":
return True
if pattern_normalized.endswith("/**"):
prefix = pattern_normalized[:-3].rstrip("/")
if normalized == prefix or normalized.startswith(prefix + "/"):
return True
elif pattern_normalized.endswith("/"):
if normalized.startswith(pattern_normalized):
return True
elif normalized == pattern_normalized or normalized.startswith(pattern_normalized.rstrip("/") + "/"):
return True
return False
def parse_kv_arg(arg:str) -> tuple[str, str]|None:
if "=" not in arg:
return None
key, value = arg.split("=", 1)
if not key:
return None
return key, value
def extract_command_target(args:list[str]) -> CommandTarget:
vault:str|None = None
command:str|None = None
paths:list[str] = []
for arg in args:
parsed = parse_kv_arg(arg)
if parsed and parsed[0] == "vault" and command is None:
vault = parsed[1]
continue
command = arg
break
for arg in args:
parsed = parse_kv_arg(arg)
if not parsed:
continue
key, value = parsed
if key in PATH_KEYS and value:
paths.append(value)
return CommandTarget(command=command, vault=vault, paths=tuple(paths))
def authorize_command(policy:TokenPolicy|None, args:list[str], cwd:Path) -> None:
if policy is None:
return
target = extract_command_target(args)
command = target.command or ""
if not policy.allows_all_commands() and command not in policy.commands:
raise PermissionError(f"token is not allowed to run command: {command}")
if not policy.allows_all_vaults():
vault = target.vault or vault_for_path(cwd) or DEFAULT_VAULT_NAME
if vault not in policy.vaults:
raise PermissionError(f"token is not allowed to access vault: {vault}")
if policy.allows_all_paths():
return
if not target.paths:
if command in PATHLESS_ALLOWED_WHEN_RESTRICTED:
return
if command in PATH_REQUIRED_WHEN_RESTRICTED:
raise PermissionError("token has path restrictions; command must include an allowed path/file/folder/name argument")
raise PermissionError(f"token has path restrictions; command is not allowed without an explicit path: {command}")
denied = [path for path in target.paths if not is_path_allowed(path, policy.paths)]
if denied:
raise PermissionError(f"token is not allowed to access path: {denied[0]}")
+58
View File
@@ -0,0 +1,58 @@
import asyncio, uuid, time
from pathlib import Path
from starlette.responses import JSONResponse
from app.config import CLI_BIN
def error(status_code:int, detail:str) -> JSONResponse:
return JSONResponse({"detail": detail}, status_code=status_code)
async def execute_command(args:list[str], cwd:Path, stdin:str|None, timeout:float) -> JSONResponse:
command_id = str(uuid.uuid4())
command = [CLI_BIN, *args]
started = time.perf_counter()
try:
process = await asyncio.create_subprocess_exec(
*command,
cwd=str(cwd),
stdin=asyncio.subprocess.PIPE if stdin is not None else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError:
return error(500, f"configured CLI binary was not found: {CLI_BIN}")
try:
stdout_b, stderr_b = await asyncio.wait_for(
process.communicate(None if stdin is None else stdin.encode()),
timeout=timeout,
)
timed_out = False
except asyncio.TimeoutError:
process.kill()
stdout_b, stderr_b = await process.communicate()
timed_out = True
duration_ms = int((time.perf_counter() - started) * 1000)
stdout = stdout_b.decode(errors="replace")
stderr = stderr_b.decode(errors="replace")
exit_code = process.returncode if process.returncode is not None else -1
if timed_out:
exit_code = 124
stderr = (stderr + f"\nCommand timed out after {timeout}s").strip()
return JSONResponse(
{
"id": command_id,
"command": command,
"cwd": str(cwd),
"exit_code": exit_code,
"stdout": stdout,
"stderr": stderr,
"duration_ms": duration_ms,
"timed_out": timed_out,
}
)