Route hostd calls through controlplane API, remove direct socket from extension

Add POST /api/controlplane/hostd endpoint that proxies to the host's
hostd Unix socket. Rewrite extension hostd-bridge to use HTTP instead
of raw socket — works identically on host and in jails, no mount hacks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mevy Assistant 2026-04-19 06:26:42 +00:00
parent fa992de585
commit cac8b4ae05
2 changed files with 88 additions and 77 deletions

View file

@ -1,19 +1,11 @@
/**
* hostd-bridge.ts Self-contained wrapper around the hostd Unix socket.
* hostd-bridge.ts HTTP proxy to the controlplane's /api/controlplane/hostd endpoint.
*
* Must be loadable inside jails via /opt/pi-extensions without depending on
* the repo's src/ tree or node_modules beyond built-in Node APIs.
* All hostd calls go through the controlplane API, which owns the Unix socket
* on the host. Works identically on host and inside jails no direct socket
* access needed.
*/
import fs from 'node:fs';
import net from 'node:net';
export interface HostdResponse {
ok: boolean;
output?: string;
error?: string;
exitCode?: number;
id?: string;
}
import http from 'node:http';
export interface HostdCallResult {
ok: boolean;
@ -21,88 +13,69 @@ export interface HostdCallResult {
exitCode?: number;
}
function detectSocketPath(): string {
const candidates = [
process.env.HOSTD_SOCKET,
process.env.MEVY_HOSTD_SOCKET,
'/var/run/mevy-hostd.sock',
'/var/run/clawdie-hostd.sock',
].filter(Boolean) as string[];
for (const p of candidates) {
try {
if (fs.existsSync(p)) return p;
} catch {
// ignore
}
}
// Fall back to the most common default; the connect will fail and return a tool error.
return '/var/run/mevy-hostd.sock';
}
const API_URL = process.env.CONTROLPLANE_API_URL ?? 'http://127.0.0.1:3100';
const API_KEY = process.env.CONTROLPLANE_API_KEY ?? '';
/**
* Call a hostd operation. Returns a normalised result never throws.
* Tools use this so errors show as tool output, not crashes.
* Call a hostd operation via the controlplane API. Returns a normalised
* result never throws. Tools use this so errors show as tool output,
* not crashes.
*/
export async function callHostd(
op: string,
params: Record<string, string | number | boolean> = {},
): Promise<HostdCallResult> {
const socketPath = detectSocketPath();
const id = Math.random().toString(36).slice(2);
const payload = JSON.stringify({ id, op, params }) + '\n';
const body = JSON.stringify({ op, params });
const url = new URL('/api/controlplane/hostd', API_URL);
return new Promise((resolve) => {
let settled = false;
const socket = net.createConnection(socketPath);
let buf = '';
const req = http.request(
url,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
...(API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}),
},
timeout: 30_000,
},
(res) => {
let data = '';
res.on('data', (chunk: Buffer) => {
data += chunk.toString();
});
res.on('end', () => {
try {
const parsed = JSON.parse(data);
resolve({
ok: !!parsed.ok,
output: parsed.output ?? (parsed.ok ? 'ok' : (parsed.error ?? 'error')),
exitCode: parsed.exitCode,
});
} catch {
resolve({ ok: false, output: `hostd: bad API response: ${data.slice(0, 200)}` });
}
});
},
);
const finish = (ok: boolean, output: string) => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch { /* ignore */ }
resolve({ ok, output });
};
const timer = setTimeout(() => {
finish(false, `hostd timeout (${socketPath})`);
}, 3000);
socket.on('connect', () => {
socket.write(payload);
req.on('error', (err) => {
resolve({ ok: false, output: `hostd API error: ${err.message}` });
});
socket.on('data', (d) => {
buf += d.toString();
const nl = buf.indexOf('\n');
if (nl === -1) return;
clearTimeout(timer);
const line = buf.slice(0, nl);
try {
const resp = JSON.parse(line) as HostdResponse;
if (resp.id && resp.id !== id) return;
finish(
!!resp.ok,
resp.output ?? (resp.ok ? 'ok' : (resp.error ?? 'error')),
);
} catch (err) {
finish(false, `hostd bad response: ${err instanceof Error ? err.message : String(err)}`);
}
req.on('timeout', () => {
req.destroy();
resolve({ ok: false, output: 'hostd API timeout' });
});
socket.on('error', (err) => {
clearTimeout(timer);
finish(false, `hostd error (${socketPath}): ${err.message}`);
});
socket.on('close', () => {
clearTimeout(timer);
if (!settled) finish(false, `hostd connection closed (${socketPath})`);
});
req.write(body);
req.end();
});
}
/**
* Check if hostd is reachable at all (used by harness-check).
* Check if hostd is reachable (via the controlplane API).
*/
export async function hostdReachable(): Promise<boolean> {
const r = await callHostd('bastille-list');

View file

@ -34,6 +34,7 @@ import {
type ActivityEvent,
type Approval,
} from './controlplane-db.js';
import { hostd } from './hostd/client.js';
import { logger } from './logger.js';
// ── Types ──────────────────────────────────────────────────────────────────
@ -508,6 +509,35 @@ function serveDashboard(
res.end(content);
}
async function handlePostHostd(
req: http.IncomingMessage,
res: http.ServerResponse,
): Promise<void> {
const body = await readBody(req);
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(body);
} catch {
jsonResponse(res, 400, { error: 'invalid JSON' });
return;
}
const op = typeof parsed.op === 'string' ? parsed.op : '';
if (!op) {
jsonResponse(res, 400, { error: 'missing op' });
return;
}
const params = (typeof parsed.params === 'object' && parsed.params !== null
? parsed.params
: {}) as Record<string, string | number | boolean>;
try {
const result = await hostd(op, params);
jsonResponse(res, 200, result);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
jsonResponse(res, 502, { ok: false, error: msg });
}
}
// ── Router ─────────────────────────────────────────────────────────────────
function matchApiRoute(method: string, pathname: string): string | null {
@ -526,6 +556,8 @@ function matchApiRoute(method: string, pathname: string): string | null {
return 'get_activity';
if (method === 'POST' && pathname === '/api/controlplane/activity')
return 'activity';
if (method === 'POST' && pathname === '/api/controlplane/hostd')
return 'hostd';
if (method === 'GET' && pathname === '/api/controlplane/approvals')
return 'approvals';
if (
@ -620,6 +652,9 @@ export function createControlplaneApiHandler(
case 'reject':
await handleApproveOrReject(pool, req, res, false);
break;
case 'hostd':
await handlePostHostd(req, res);
break;
}
return;
}
@ -662,6 +697,9 @@ export function createControlplaneApiHandler(
case 'reject':
await handleApproveOrReject(pool, req, res, false);
break;
case 'hostd':
await handlePostHostd(req, res);
break;
}
} catch (err) {
logger.error({ err }, 'controlplane-api: unhandled error');