BeforeTool-hook + kort-vy (strukturerade godkännanden) + release på master-push
All checks were successful
build-and-push / build (push) Successful in 22s
release-client / build-release (push) Successful in 48s

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:
2026-06-28 18:03:12 +02:00
parent a06a02927c
commit 98c4d13360
9 changed files with 460 additions and 97 deletions

View File

@@ -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();
}