sidecar.js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectViaSidecar = detectViaSidecar;
exports.warmSidecar = warmSidecar;
const node_child_process_1 = require("node:child_process");
const node_fs_1 = __importDefault(require("node:fs"));
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
/**
* The plugin root. The compiled plugin code lives at the repo root (tsc
* output) or in `.lmstudio/dev.js` (lms dev bundle) — the worker script is
* always at `<plugin root>/sidecar/worker.mjs`.
*/
function pluginRoot() {
const candidates = [__dirname, node_path_1.default.join(__dirname, "..")];
for (const dir of candidates) {
if (node_fs_1.default.existsSync(node_path_1.default.join(dir, "sidecar", "worker.mjs"))) {
return dir;
}
}
throw new Error(`sidecar worker script not found (tried: ${candidates.map(c => node_path_1.default.join(c, "sidecar", "worker.mjs")).join(", ")})`);
}
const WORKER_PATH = node_path_1.default.join(pluginRoot(), "sidecar", "worker.mjs");
/**
* Cache directory for the re-signed runner binary. The copy is named
* `pii-redact-node` so the Gatekeeper prompt (if any) identifies it clearly.
*/
const CACHE_DIR = node_path_1.default.join(node_os_1.default.homedir(), ".cache", "pii-redact");
const RUNNER_PATH = node_path_1.default.join(CACHE_DIR, "pii-redact-node");
// ---------------------------------------------------------------------------
// Runner preparation
// ---------------------------------------------------------------------------
/**
* Prepare a re-signed copy of the plugin runner binary.
*
* LM Studio's plugin runner is signed with a Team ID and enforces macOS
* library validation, so native addons (koffi) cannot be loaded in-process
* (lmstudio-bug-tracker#1494). An ad-hoc re-signed copy drops the hardened
* runtime flag, which disables library validation.
*
* Steps:
* 1. Copy the runner binary into the cache dir (re-done when the source
* binary changes, e.g. after an LM Studio update).
* 2. Strip all extended attributes — macOS kills re-signed binaries that
* still carry `provenance`/`quarantine` xattrs inherited from the app
* bundle.
* 3. Ad-hoc re-sign (`codesign --force --sign -`).
*/
function ensureRunner() {
const source = process.execPath;
const srcStat = node_fs_1.default.statSync(source);
let needsRebuild = true;
try {
const dstStat = node_fs_1.default.statSync(RUNNER_PATH);
needsRebuild =
dstStat.size !== srcStat.size || Math.floor(dstStat.mtimeMs) !== Math.floor(srcStat.mtimeMs);
}
catch {
// Copy does not exist yet
}
if (needsRebuild) {
node_fs_1.default.mkdirSync(CACHE_DIR, { recursive: true });
node_fs_1.default.copyFileSync(source, RUNNER_PATH);
node_fs_1.default.chmodSync(RUNNER_PATH, 0o755);
// macOS-only helpers — the sidecar is only used on macOS (where library
// validation blocks native addons); on Linux/Windows the in-process path
// works and this code is never reached.
(0, node_child_process_1.execFileSync)("/usr/bin/xattr", ["-c", RUNNER_PATH]);
try {
// Suppress codesign's normal output ("replacing existing signature") —
// only failures are surfaced.
(0, node_child_process_1.execFileSync)("/usr/bin/codesign", ["--force", "--sign", "-", RUNNER_PATH], {
stdio: ["ignore", "ignore", "pipe"],
});
}
catch (err) {
const stderr = err.stderr?.toString().trim();
throw new Error(`failed to re-sign the sidecar runner copy (${RUNNER_PATH}): ` +
(stderr || String(err)));
}
}
return RUNNER_PATH;
}
let worker = null;
let nextId = 1;
const pending = new Map();
function spawnWorker() {
const runner = ensureRunner();
const child = (0, node_child_process_1.spawn)(runner, [WORKER_PATH], {
cwd: pluginRoot(),
stdio: ["pipe", "pipe", "pipe"],
});
child.on("exit", () => {
// Reject all in-flight requests and drop the worker so the next
// detection spawns a fresh one.
for (const p of pending.values()) {
p.reject(new Error("sidecar worker exited unexpectedly"));
}
pending.clear();
worker = null;
});
// Worker stderr → plugin stderr (shows up in the lms dev terminal)
child.stderr.on("data", chunk => {
process.stderr.write(chunk);
});
let outBuf = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", chunk => {
outBuf += chunk;
let idx;
while ((idx = outBuf.indexOf("\n")) !== -1) {
const line = outBuf.slice(0, idx);
outBuf = outBuf.slice(idx + 1);
if (line.trim() === "")
continue;
handleWorkerMessage(line);
}
});
return child;
}
function handleWorkerMessage(line) {
let msg;
try {
msg = JSON.parse(line);
}
catch {
process.stderr.write(`[pii-redact] sidecar sent invalid JSON: ${line}\n`);
return;
}
if (msg.id === null || msg.id === undefined) {
process.stderr.write(`[pii-redact] sidecar error: ${msg.message ?? "unknown"}\n`);
return;
}
const p = pending.get(msg.id);
if (p === undefined) {
// Late response (e.g. after an abort) — ignore
return;
}
pending.delete(msg.id);
if (msg.type === "result" && msg.spans !== undefined) {
p.resolve(msg.spans);
}
else if (msg.type === "ready") {
p.resolve(undefined);
}
else {
p.reject(new Error(msg.message ?? "sidecar request failed"));
}
}
function getWorker() {
if (worker === null) {
worker = spawnWorker();
}
return worker;
}
function request(type, text) {
const child = getWorker();
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
child.stdin.write(JSON.stringify({ type, id, text }) + "\n");
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Detect PII via the sidecar worker. Returns spans in document order, using
* Redact's own labels.
*/
async function detectViaSidecar(text) {
return (await request("detect", text));
}
/**
* Warm up the sidecar: spawn the worker and trigger the Redact model load
* (weights download on first use). Called fire-and-forget from main() so the
* first preprocessor request does not pay the startup cost.
*/
async function warmSidecar() {
await request("warmup");
}
sidecar.js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.detectViaSidecar = detectViaSidecar;
exports.warmSidecar = warmSidecar;
const node_child_process_1 = require("node:child_process");
const node_fs_1 = __importDefault(require("node:fs"));
const node_os_1 = __importDefault(require("node:os"));
const node_path_1 = __importDefault(require("node:path"));
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
/**
* The plugin root. The compiled plugin code lives at the repo root (tsc
* output) or in `.lmstudio/dev.js` (lms dev bundle) — the worker script is
* always at `<plugin root>/sidecar/worker.mjs`.
*/
function pluginRoot() {
const candidates = [__dirname, node_path_1.default.join(__dirname, "..")];
for (const dir of candidates) {
if (node_fs_1.default.existsSync(node_path_1.default.join(dir, "sidecar", "worker.mjs"))) {
return dir;
}
}
throw new Error(`sidecar worker script not found (tried: ${candidates.map(c => node_path_1.default.join(c, "sidecar", "worker.mjs")).join(", ")})`);
}
const WORKER_PATH = node_path_1.default.join(pluginRoot(), "sidecar", "worker.mjs");
/**
* Cache directory for the re-signed runner binary. The copy is named
* `pii-redact-node` so the Gatekeeper prompt (if any) identifies it clearly.
*/
const CACHE_DIR = node_path_1.default.join(node_os_1.default.homedir(), ".cache", "pii-redact");
const RUNNER_PATH = node_path_1.default.join(CACHE_DIR, "pii-redact-node");
// ---------------------------------------------------------------------------
// Runner preparation
// ---------------------------------------------------------------------------
/**
* Prepare a re-signed copy of the plugin runner binary.
*
* LM Studio's plugin runner is signed with a Team ID and enforces macOS
* library validation, so native addons (koffi) cannot be loaded in-process
* (lmstudio-bug-tracker#1494). An ad-hoc re-signed copy drops the hardened
* runtime flag, which disables library validation.
*
* Steps:
* 1. Copy the runner binary into the cache dir (re-done when the source
* binary changes, e.g. after an LM Studio update).
* 2. Strip all extended attributes — macOS kills re-signed binaries that
* still carry `provenance`/`quarantine` xattrs inherited from the app
* bundle.
* 3. Ad-hoc re-sign (`codesign --force --sign -`).
*/
function ensureRunner() {
const source = process.execPath;
const srcStat = node_fs_1.default.statSync(source);
let needsRebuild = true;
try {
const dstStat = node_fs_1.default.statSync(RUNNER_PATH);
needsRebuild =
dstStat.size !== srcStat.size || Math.floor(dstStat.mtimeMs) !== Math.floor(srcStat.mtimeMs);
}
catch {
// Copy does not exist yet
}
if (needsRebuild) {
node_fs_1.default.mkdirSync(CACHE_DIR, { recursive: true });
node_fs_1.default.copyFileSync(source, RUNNER_PATH);
node_fs_1.default.chmodSync(RUNNER_PATH, 0o755);
// macOS-only helpers — the sidecar is only used on macOS (where library
// validation blocks native addons); on Linux/Windows the in-process path
// works and this code is never reached.
(0, node_child_process_1.execFileSync)("/usr/bin/xattr", ["-c", RUNNER_PATH]);
try {
// Suppress codesign's normal output ("replacing existing signature") —
// only failures are surfaced.
(0, node_child_process_1.execFileSync)("/usr/bin/codesign", ["--force", "--sign", "-", RUNNER_PATH], {
stdio: ["ignore", "ignore", "pipe"],
});
}
catch (err) {
const stderr = err.stderr?.toString().trim();
throw new Error(`failed to re-sign the sidecar runner copy (${RUNNER_PATH}): ` +
(stderr || String(err)));
}
}
return RUNNER_PATH;
}
let worker = null;
let nextId = 1;
const pending = new Map();
function spawnWorker() {
const runner = ensureRunner();
const child = (0, node_child_process_1.spawn)(runner, [WORKER_PATH], {
cwd: pluginRoot(),
stdio: ["pipe", "pipe", "pipe"],
});
child.on("exit", () => {
// Reject all in-flight requests and drop the worker so the next
// detection spawns a fresh one.
for (const p of pending.values()) {
p.reject(new Error("sidecar worker exited unexpectedly"));
}
pending.clear();
worker = null;
});
// Worker stderr → plugin stderr (shows up in the lms dev terminal)
child.stderr.on("data", chunk => {
process.stderr.write(chunk);
});
let outBuf = "";
child.stdout.setEncoding("utf8");
child.stdout.on("data", chunk => {
outBuf += chunk;
let idx;
while ((idx = outBuf.indexOf("\n")) !== -1) {
const line = outBuf.slice(0, idx);
outBuf = outBuf.slice(idx + 1);
if (line.trim() === "")
continue;
handleWorkerMessage(line);
}
});
return child;
}
function handleWorkerMessage(line) {
let msg;
try {
msg = JSON.parse(line);
}
catch {
process.stderr.write(`[pii-redact] sidecar sent invalid JSON: ${line}\n`);
return;
}
if (msg.id === null || msg.id === undefined) {
process.stderr.write(`[pii-redact] sidecar error: ${msg.message ?? "unknown"}\n`);
return;
}
const p = pending.get(msg.id);
if (p === undefined) {
// Late response (e.g. after an abort) — ignore
return;
}
pending.delete(msg.id);
if (msg.type === "result" && msg.spans !== undefined) {
p.resolve(msg.spans);
}
else if (msg.type === "ready") {
p.resolve(undefined);
}
else {
p.reject(new Error(msg.message ?? "sidecar request failed"));
}
}
function getWorker() {
if (worker === null) {
worker = spawnWorker();
}
return worker;
}
function request(type, text) {
const child = getWorker();
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
child.stdin.write(JSON.stringify({ type, id, text }) + "\n");
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Detect PII via the sidecar worker. Returns spans in document order, using
* Redact's own labels.
*/
async function detectViaSidecar(text) {
return (await request("detect", text));
}
/**
* Warm up the sidecar: spawn the worker and trigger the Redact model load
* (weights download on first use). Called fire-and-forget from main() so the
* first preprocessor request does not pay the startup cost.
*/
async function warmSidecar() {
await request("warmup");
}