src / bridge.ts
/**
* Secure local bridge to the Windows OneNote COM API.
*
* Ported from local-onenote-mcp's bridge.py. The OneNote COM type library is
* not reliably registered for other automation stacks, but the desktop app is
* scriptable through PowerShell — so this module drives a fixed PowerShell
* program as a narrow COM bridge, directly from Node (no Python, no MCP).
*
* User data is passed only via JSON temp files, never interpolated into the
* script text, so there is no command-injection surface.
*/
import { spawn } from "child_process";
import { randomUUID } from "crypto";
import { mkdtemp, readFile, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
// The COM object is released in a finally block so repeated automation
// connections do not leave pages hydrated in OneNote's memory.
const POWERSHELL_BRIDGE = String.raw`
$ErrorActionPreference = "Stop"
function New-Ok($data) {
return @{ ok = $true; data = $data; error = $null }
}
function New-Err($err) {
$ex = $err.Exception
return @{
ok = $false
data = $null
error = @{
message = $ex.Message
hresult = $ex.HResult
category = [string]$err.CategoryInfo
}
}
}
$onenote = $null
try {
$requestPath = $env:ONENOTE_MANAGER_REQUEST
$responsePath = $env:ONENOTE_MANAGER_RESPONSE
if ([string]::IsNullOrWhiteSpace($requestPath) -or [string]::IsNullOrWhiteSpace($responsePath)) {
throw "Bridge request/response paths are not set."
}
$request = Get-Content -LiteralPath $requestPath -Raw -Encoding UTF8 | ConvertFrom-Json
$op = [string]$request.operation
$p = $request.params
$onenote = New-Object -ComObject OneNote.Application
$data = $null
switch ($op) {
"get_hierarchy" {
$xml = ""
$onenote.GetHierarchy([string]$p.start_id, [int]$p.scope, [ref]$xml, [int]$p.schema)
$data = @{ xml = $xml }
}
"open_hierarchy" {
$objectId = ""
$onenote.OpenHierarchy([string]$p.path, [string]$p.relative_to_id, [ref]$objectId, [int]$p.create_file_type)
$data = @{ object_id = $objectId }
}
"update_hierarchy" {
$onenote.UpdateHierarchy([string]$p.xml, [int]$p.schema)
$data = @{ updated = $true }
}
"delete_hierarchy" {
$onenote.DeleteHierarchy([string]$p.object_id, 0, [bool]$p.permanently)
$data = @{ deleted = $true }
}
"close_notebook" {
$onenote.CloseNotebook([string]$p.notebook_id, [bool]$p.force)
$data = @{ closed = $true }
}
"get_hierarchy_parent" {
$parentId = ""
$onenote.GetHierarchyParent([string]$p.object_id, [ref]$parentId)
$data = @{ parent_id = $parentId }
}
"get_special_location" {
$location = ""
$onenote.GetSpecialLocation([int]$p.location, [ref]$location)
$data = @{ path = $location }
}
"create_new_page" {
$pageId = ""
$onenote.CreateNewPage([string]$p.section_id, [ref]$pageId, [int]$p.new_page_style)
$data = @{ page_id = $pageId }
}
"get_page_content" {
$xml = ""
$onenote.GetPageContent([string]$p.page_id, [ref]$xml, [int]$p.page_info, [int]$p.schema)
$data = @{ xml = $xml }
}
"update_page_content" {
$onenote.UpdatePageContent([string]$p.xml, 0, [int]$p.schema, [bool]$p.force)
$data = @{ updated = $true }
}
"delete_page_content" {
$onenote.DeletePageContent([string]$p.page_id, [string]$p.object_id, 0, [bool]$p.force)
$data = @{ deleted = $true }
}
"get_binary_page_content" {
$content = ""
$onenote.GetBinaryPageContent([string]$p.page_id, [string]$p.callback_id, [ref]$content)
$data = @{ base64 = $content }
}
"publish" {
$onenote.Publish([string]$p.object_id, [string]$p.target_path, [int]$p.format, "")
$data = @{ path = [string]$p.target_path }
}
"find_pages" {
$xml = ""
$onenote.FindPages([string]$p.start_id, [string]$p.query, [ref]$xml, [bool]$p.include_unindexed, [bool]$p.display, [int]$p.schema)
$data = @{ xml = $xml }
}
"find_meta" {
$xml = ""
$onenote.FindMeta([string]$p.start_id, [string]$p.name, [ref]$xml, [bool]$p.include_unindexed, [int]$p.schema)
$data = @{ xml = $xml }
}
"get_hyperlink" {
$link = ""
$onenote.GetHyperlinkToObject([string]$p.object_id, [string]$p.page_content_object_id, [ref]$link)
$data = @{ hyperlink = $link }
}
"get_web_hyperlink" {
$link = ""
$onenote.GetWebHyperlinkToObject([string]$p.object_id, [string]$p.page_content_object_id, [ref]$link)
$data = @{ hyperlink = $link }
}
"navigate_to" {
$onenote.NavigateTo([string]$p.object_id, [string]$p.page_content_object_id, [bool]$p.new_window)
$data = @{ navigated = $true }
}
"navigate_to_url" {
$onenote.NavigateToUrl([string]$p.url, [bool]$p.new_window)
$data = @{ navigated = $true }
}
"sync_hierarchy" {
$onenote.SyncHierarchy([string]$p.object_id)
$data = @{ synced = $true }
}
"merge_sections" {
$onenote.MergeSections([string]$p.source_section_id, [string]$p.destination_section_id)
$data = @{ merged = $true }
}
"set_filing_location" {
$onenote.SetFilingLocation([int]$p.filing_location, [int]$p.filing_location_type, [string]$p.section_or_page_id)
$data = @{ updated = $true }
}
default {
throw "Unsupported OneNote bridge operation: $op"
}
}
$response = New-Ok $data
} catch {
$response = New-Err $_
} finally {
if ($onenote) {
try {
[System.Runtime.InteropServices.Marshal]::FinalReleaseComObject($onenote) | Out-Null
} catch {}
$onenote = $null
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
}
$response | ConvertTo-Json -Depth 100 -Compress | Set-Content -LiteralPath $env:ONENOTE_MANAGER_RESPONSE -Encoding UTF8
`;
export class OneNoteBridgeError extends Error {
hresult?: number;
constructor(message: string, hresult?: number) {
super(message);
this.name = "OneNoteBridgeError";
this.hresult = hresult;
}
}
export interface BridgeOptions {
timeoutSeconds?: number;
onLog?: (line: string) => void;
}
export class OneNoteBridge {
private readonly timeoutMs: number;
private readonly onLog?: (line: string) => void;
constructor(opts: BridgeOptions = {}) {
this.timeoutMs = (opts.timeoutSeconds ?? 90) * 1000;
this.onLog = opts.onLog;
}
async call(operation: string, params: Record<string, unknown> = {}): Promise<Record<string, any>> {
const dir = await mkdtemp(join(tmpdir(), "onenote-manager-"));
const reqPath = join(dir, `${randomUUID()}.req.json`);
const respPath = join(dir, `${randomUUID()}.resp.json`);
try {
await writeFile(reqPath, JSON.stringify({ operation, params }), "utf-8");
await this.runPowerShell(reqPath, respPath);
let raw: string;
try {
raw = await readFile(respPath, "utf-8");
} catch {
throw new OneNoteBridgeError("PowerShell bridge did not write a response.");
}
const response = JSON.parse(stripBom(raw));
if (!response || response.ok !== true) {
const errInfo = (response?.error as Record<string, any>) || {};
// HRESULT 0x80042014 = "OneNote is currently busy" — transient, retry with backoff.
if (errInfo.hresult === -2147216524) {
throw new OneNoteBridgeError(errInfo.message || "OneNote COM operation failed.", errInfo.hresult);
}
throw new OneNoteBridgeError(errInfo.message || "OneNote COM operation failed.", errInfo.hresult);
}
const data = response.data;
return data && typeof data === "object" ? data : { value: data };
} finally {
await rm(dir, { recursive: true, force: true }).catch(() => {});
}
}
async callWithRetry(operation: string, params: Record<string, unknown> = {}, maxRetries = 3): Promise<Record<string, any>> {
let lastErr: OneNoteBridgeError | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await this.call(operation, params);
} catch (exc: unknown) {
const err = exc instanceof OneNoteBridgeError ? exc : new OneNoteBridgeError(String((exc as Error)?.message ?? String(exc)));
// Only retry on 0x80042014 ("OneNote is currently busy").
if (err.hresult !== -2147216524 || attempt === maxRetries) {
lastErr = err;
break;
}
// Exponential backoff: 200ms, 400ms, 800ms.
await sleep(200 * (1 << attempt));
}
}
throw lastErr!;
}
private runPowerShell(reqPath: string, respPath: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawn(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", "-"],
{
windowsHide: true,
env: {
...process.env,
ONENOTE_MANAGER_REQUEST: reqPath,
ONENOTE_MANAGER_RESPONSE: respPath,
},
},
);
let stderr = "";
let stdout = "";
const timer = setTimeout(() => {
child.kill();
reject(
new OneNoteBridgeError(
`OneNote COM operation timed out after ${Math.round(this.timeoutMs / 1000)} seconds.`,
),
);
}, this.timeoutMs);
child.stdout?.on("data", (c: Buffer) => (stdout += c.toString("utf8")));
child.stderr?.on("data", (c: Buffer) => (stderr += c.toString("utf8")));
child.on("error", (e) => {
clearTimeout(timer);
reject(new OneNoteBridgeError(`Failed to launch PowerShell: ${e.message}`));
});
child.on("close", (code) => {
clearTimeout(timer);
const diag = stderr.trim() || stdout.trim();
if (diag) this.onLog?.(`[bridge] ${diag}`);
if (code !== 0) {
reject(new OneNoteBridgeError(diag || `PowerShell bridge exited with code ${code}.`));
return;
}
resolve();
});
child.stdin?.write(POWERSHELL_BRIDGE);
child.stdin?.end();
});
}
}
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
function stripBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}