BeforeTool-hook + kort-vy (strukturerade godkännanden) + release på master-push
Feature: strukturerade verktygsgodkännanden
- shared: protokoll för approval (daemon:approval, server:approval[-resolved],
web:approval-decision, server:approval-decision)
- daemon: hook-läge ('agent-helm hook' — samma binär), lokal 127.0.0.1-endpoint
som hooken POST:ar till, long-poll tills beslut; graceful default (allow) när
ingen UI; timeout-fallback; skickar AGENT_HELM_HOOK_PORT till agentens env
- server: routar daemon:approval -> prenumeranter, beslut -> daemon + döljer kort
- web: reaktiv approvals-lista + kort-overlay (Tillåt/Neka), mobilanpassad
CI:
- release.yaml triggar nu även på master-push -> rullande 'latest'-release
(versionerade releaser kvar på v*-taggar); install.sh hämtar 'latest'
Docs: hook-konfig (gemini settings.json) + hook-env i .env.example + DEPLOY.md.
Säkerhet: hook-endpoint binder endast 127.0.0.1; inga hemligheter i repot.
Verifierat lokalt: hook->daemon->server->kort->beslut->hook ger {"decision":"allow"};
alla paket typecheckar; web bygger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,103 +1,238 @@
|
||||
/**
|
||||
* agent-helm daemon ("appen").
|
||||
* agent-helm daemon ("appen") + hook-läge.
|
||||
*
|
||||
* Spawnar en agent (default: gemini) i en pty, strömmar pty-output utåt till
|
||||
* control-plane-servern över WebSocket, och skriver tillbaka input som kommer
|
||||
* från servern till pty:ns stdin. Återansluter automatiskt om servern går ner.
|
||||
* Normalläge: spawnar en agent (default gemini) i en pty, strömmar pty över WS till
|
||||
* servern och skriver tillbaka input. Kör dessutom en LOKAL http-endpoint (endast
|
||||
* 127.0.0.1) som BeforeTool-hooken anropar för att få ett godkännande-beslut från UI:t.
|
||||
*
|
||||
* Persistens (att daemonen själv överlever) är ett yttre lager: kör den i tmux
|
||||
* eller som en Docker-container med restart-policy. Det hanteras inte här.
|
||||
* Hook-läge (`agent-helm hook`): läser verktygsanropet på stdin, frågar den körande
|
||||
* daemonen via den lokala endpointen, och skriver beslutet på stdout i Geminis format.
|
||||
* Samma binär gör båda jobben -> inga extra beroenden på målet.
|
||||
*
|
||||
* Persistens (att daemonen själv överlever) är ett yttre lager: tmux eller en
|
||||
* Docker-container med restart-policy. Hanteras inte här.
|
||||
*/
|
||||
import { spawn, type IPty } from "@homebridge/node-pty-prebuilt-multiarch";
|
||||
import { WebSocket } from "ws";
|
||||
import http from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import type { DaemonMessage, ServerToDaemon } from "@agent-helm/shared";
|
||||
import { parseMessage } from "@agent-helm/shared";
|
||||
|
||||
const TOKEN = process.env.AGENT_HELM_TOKEN ?? "";
|
||||
const SERVER_URL = process.env.SERVER_URL ?? "ws://localhost:8787";
|
||||
const AGENT_CMD = process.env.AGENT_CMD ?? "gemini";
|
||||
const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : [];
|
||||
const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd();
|
||||
const TITLE = process.env.AGENT_TITLE ?? `${AGENT_CMD} @ ${os.hostname()}`;
|
||||
type Decision = { decision: "allow" | "deny"; reason?: string };
|
||||
|
||||
// Fast pty-storlek: undviker resize-churn när olika klienter (mobil/desktop)
|
||||
// tittar samtidigt. Frontenden skalar/scrollar istället för att reflowa.
|
||||
const COLS = 100;
|
||||
const ROWS = 30;
|
||||
const HOOK_PORT = Number(process.env.AGENT_HELM_HOOK_PORT ?? 8788);
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error("[daemon] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön.");
|
||||
process.exit(1);
|
||||
if (process.argv[2] === "hook") {
|
||||
void runHookMode();
|
||||
} else {
|
||||
runDaemon();
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
/* --------------------------------- hook-läge -------------------------------- */
|
||||
|
||||
const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, {
|
||||
name: "xterm-256color",
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
cwd: AGENT_CWD,
|
||||
env: { ...process.env } as Record<string, string>,
|
||||
});
|
||||
|
||||
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid}), session ${sessionId}`);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function send(msg: DaemonMessage): void {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
console.log(`[daemon] ansluter till ${SERVER_URL} ...`);
|
||||
ws = new WebSocket(SERVER_URL);
|
||||
|
||||
ws.on("open", () => {
|
||||
console.log("[daemon] ansluten — registrerar session");
|
||||
send({ type: "daemon:hello", token: TOKEN, sessionId, title: TITLE, cols: COLS, rows: ROWS });
|
||||
});
|
||||
|
||||
ws.on("message", (raw) => {
|
||||
const msg = parseMessage<ServerToDaemon>(raw.toString());
|
||||
if (msg?.type === "server:input") {
|
||||
pty.write(msg.data);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log("[daemon] anslutning stängd — återansluter om 2s");
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
ws.on("error", (err: Error) => {
|
||||
console.error("[daemon] ws-fel:", err.message);
|
||||
function readStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (c) => (data += c));
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
setTimeout(() => resolve(data), 1500); // fortsätt även om stdin aldrig stängs
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 2000);
|
||||
}
|
||||
async function runHookMode(): Promise<void> {
|
||||
const raw = await readStdin();
|
||||
let payload: Record<string, unknown> = {};
|
||||
try {
|
||||
payload = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
// Geminis hook-payload kan variera mellan versioner — plocka defensivt.
|
||||
const p = payload as Record<string, any>;
|
||||
const toolName: string =
|
||||
p.tool_name ?? p.toolName ?? p.tool?.name ?? p.name ?? "okänt verktyg";
|
||||
const toolInput: unknown =
|
||||
p.tool_input ?? p.toolInput ?? p.tool?.input ?? p.arguments ?? p.args ?? payload;
|
||||
|
||||
pty.onData((data) => send({ type: "daemon:output", data }));
|
||||
|
||||
pty.onExit(({ exitCode }) => {
|
||||
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
|
||||
send({ type: "daemon:exit", code: exitCode });
|
||||
setTimeout(() => process.exit(exitCode), 200);
|
||||
});
|
||||
|
||||
function shutdown(): void {
|
||||
pty.kill();
|
||||
const decision = await askDaemon(toolName, toolInput);
|
||||
if (decision.decision === "deny") {
|
||||
process.stdout.write(JSON.stringify({ decision: "deny", reason: decision.reason ?? "Nekat via agent-helm" }));
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify({ decision: "allow" }));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
connect();
|
||||
function askDaemon(toolName: string, toolInput: unknown): Promise<Decision> {
|
||||
return new Promise((resolve) => {
|
||||
const body = JSON.stringify({ toolName, toolInput });
|
||||
const req = http.request(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
port: HOOK_PORT,
|
||||
path: "/approve",
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "content-length": Buffer.byteLength(body) },
|
||||
},
|
||||
(res) => {
|
||||
let out = "";
|
||||
res.on("data", (c) => (out += c));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(out) as Decision);
|
||||
} catch {
|
||||
resolve({ decision: "allow" });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
// Ingen daemon igång / fel -> tillåt (graceful: agenten funkar som utan hook).
|
||||
req.on("error", () => resolve({ decision: "allow" }));
|
||||
req.setTimeout(180000, () => {
|
||||
req.destroy();
|
||||
resolve({ decision: "allow" });
|
||||
});
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------- normal daemon ----------------------------- */
|
||||
|
||||
function runDaemon(): void {
|
||||
const TOKEN = process.env.AGENT_HELM_TOKEN ?? "";
|
||||
const SERVER_URL = process.env.SERVER_URL ?? "ws://localhost:8787";
|
||||
const AGENT_CMD = process.env.AGENT_CMD ?? "gemini";
|
||||
const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : [];
|
||||
const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd();
|
||||
const TITLE = process.env.AGENT_TITLE ?? `${AGENT_CMD} @ ${os.hostname()}`;
|
||||
const HOOK_DEFAULT: "allow" | "deny" = process.env.AGENT_HELM_HOOK_DEFAULT === "deny" ? "deny" : "allow";
|
||||
const HOOK_TIMEOUT = Number(process.env.AGENT_HELM_HOOK_TIMEOUT ?? 120000);
|
||||
const COLS = 100;
|
||||
const ROWS = 30;
|
||||
|
||||
if (!TOKEN) {
|
||||
console.error("[daemon] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, {
|
||||
name: "xterm-256color",
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
cwd: AGENT_CWD,
|
||||
// Skicka hook-porten vidare så att `agent-helm hook` (som gemini spawnar) hittar oss.
|
||||
env: { ...process.env, AGENT_HELM_HOOK_PORT: String(HOOK_PORT) } as Record<string, string>,
|
||||
});
|
||||
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid}), session ${sessionId}`);
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pending = new Map<string, (d: Decision) => void>();
|
||||
|
||||
function send(msg: DaemonMessage): void {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
console.log(`[daemon] ansluter till ${SERVER_URL} ...`);
|
||||
ws = new WebSocket(SERVER_URL);
|
||||
ws.on("open", () => {
|
||||
console.log("[daemon] ansluten — registrerar session");
|
||||
send({ type: "daemon:hello", token: TOKEN, sessionId, title: TITLE, cols: COLS, rows: ROWS });
|
||||
});
|
||||
ws.on("message", (raw) => {
|
||||
const msg = parseMessage<ServerToDaemon>(raw.toString());
|
||||
if (!msg) return;
|
||||
if (msg.type === "server:input") {
|
||||
pty.write(msg.data);
|
||||
} else if (msg.type === "server:approval-decision") {
|
||||
const resolve = pending.get(msg.approvalId);
|
||||
if (resolve) {
|
||||
pending.delete(msg.approvalId);
|
||||
resolve({ decision: msg.decision, reason: msg.reason });
|
||||
}
|
||||
}
|
||||
});
|
||||
ws.on("close", () => {
|
||||
console.log("[daemon] anslutning stängd — återansluter om 2s");
|
||||
scheduleReconnect();
|
||||
});
|
||||
ws.on("error", (err: Error) => console.error("[daemon] ws-fel:", err.message));
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
pty.onData((data) => send({ type: "daemon:output", data }));
|
||||
pty.onExit(({ exitCode }) => {
|
||||
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
|
||||
send({ type: "daemon:exit", code: exitCode });
|
||||
setTimeout(() => process.exit(exitCode), 200);
|
||||
});
|
||||
|
||||
// Lokal hook-endpoint (endast 127.0.0.1 — aldrig nåbar utifrån).
|
||||
const hookServer = http.createServer((req, res) => {
|
||||
if (req.method !== "POST" || req.url !== "/approve") {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
let parsed: { toolName?: string; toolInput?: unknown } = {};
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
/* tom */
|
||||
}
|
||||
const toolName = parsed.toolName ?? "okänt verktyg";
|
||||
const approvalId = randomUUID();
|
||||
|
||||
const finish = (d: Decision) => {
|
||||
if (res.writableEnded) return;
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(d));
|
||||
};
|
||||
|
||||
// Ingen server ansluten -> svara med default direkt (graceful degradation).
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
finish({ decision: HOOK_DEFAULT });
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (pending.delete(approvalId)) finish({ decision: HOOK_DEFAULT, reason: "timeout" });
|
||||
}, HOOK_TIMEOUT);
|
||||
pending.set(approvalId, (d) => {
|
||||
clearTimeout(timer);
|
||||
finish(d);
|
||||
});
|
||||
|
||||
send({ type: "daemon:approval", approvalId, toolName, toolInput: parsed.toolInput });
|
||||
});
|
||||
});
|
||||
hookServer.listen(HOOK_PORT, "127.0.0.1", () => {
|
||||
console.log(`[daemon] hook-endpoint på 127.0.0.1:${HOOK_PORT} (default vid ingen UI: ${HOOK_DEFAULT})`);
|
||||
});
|
||||
|
||||
function shutdown(): void {
|
||||
pty.kill();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user