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:
parent
fa992de585
commit
cac8b4ae05
2 changed files with 88 additions and 77 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue