Files
agent-helm/packages/daemon/src/index.ts
Bjorn Blomberg b3204de62e
All checks were successful
build-and-push / build (push) Successful in 9s
release-client / build-release (push) Successful in 46s
daemon: fixa TDZ-dispatch + hook-port-per-instans (flera klienter/host)
Två buggar som hittades vid test mot riktiga servern:

1. Dispatchen (`void runConnect()`) låg överst i filen, ovanför module-level
   const CONFIG_DIR/CRED_FILE. runConnect:s synkrona del (fram till första
   await) kördes då under modul-init medan de const:erna var i TDZ → loadCreds()
   fick undefined path → returnerade null → klienten körde alltid nytt
   device-flöde istället för att återuppta. Flyttad sist i filen.

2. Hook-porten var hårdkodad (8788) → flera daemon-instanser på samma host
   krockade (EADDRINUSE) och kraschade. Nu: bind hook-porten FÖRST på en
   ephemeral port (om AGENT_HELM_HOOK_PORT ej satt) och skicka faktiska porten
   till agenten; bind-fel är icke-fatalt (kör vidare utan hook).

Verifierat mot rcai.brasse-pc.eu: resume återupptar rätt session, --new ger en
parallell session, två instanser kör samtidigt med var sin hook-port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U68YyHsxU91ecsen84WQV4
2026-06-29 01:42:39 +02:00

394 lines
15 KiB
TypeScript

/**
* agent-helm daemon ("klienten") + hook-läge.
*
* Underkommandon:
* agent-helm connect [--server URL] [--name N] [--new] [--login]
* Loggar in mot servern via device-flödet första gången (skriver ut en
* URL att öppna + godkänna i webben), sparar ett klient-token lokalt, och
* kör sedan agenten i en pty och strömmar den över WS. Nästa start är tyst.
* --new -> starta en ny parallell session istället för att återuppta.
* --login -> tvinga ny inloggning (t.ex. byt användare/server).
* agent-helm logout -> radera sparat klient-token.
* agent-helm whoami -> visa sparad inloggning.
* agent-helm hook -> internt: anropas av agentens BeforeTool-hook.
*
* Klient-token + senaste session sparas i ~/.config/agent-helm/credentials.json.
*/
import { spawn, type IPty } from "@homebridge/node-pty-prebuilt-multiarch";
import { WebSocket } from "ws";
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { randomUUID } from "node:crypto";
import type { DaemonMessage, DeviceCodeResponse, DeviceTokenResponse } from "@agent-helm/shared";
import { parseMessage } from "@agent-helm/shared";
type Decision = { decision: "allow" | "deny"; reason?: string };
const HOOK_PORT = Number(process.env.AGENT_HELM_HOOK_PORT ?? 8788);
const AGENT_CMD = process.env.AGENT_CMD ?? "gemini";
/* ------------------------------ creds-hantering ---------------------------- */
interface Creds {
server: string;
token: string;
clientName: string;
user: string;
lastSessionId?: string;
}
const CONFIG_DIR = process.env.AGENT_HELM_CONFIG_DIR || path.join(os.homedir(), ".config", "agent-helm");
const CRED_FILE = path.join(CONFIG_DIR, "credentials.json");
function loadCreds(): Creds | null {
try {
return JSON.parse(fs.readFileSync(CRED_FILE, "utf8")) as Creds;
} catch {
return null;
}
}
function saveCreds(c: Creds): void {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CRED_FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
}
function logout(): void {
try {
fs.unlinkSync(CRED_FILE);
console.log("Utloggad — klient-token raderat.");
} catch {
console.log("Inget sparat token.");
}
process.exit(0);
}
function whoami(): void {
const c = loadCreds();
if (!c) {
console.log("Ej inloggad.");
} else {
console.log(`Inloggad som ${c.user} mot ${c.server} (klient: ${c.clientName})`);
}
process.exit(0);
}
/* ------------------------------ CLI-argument ------------------------------- */
function flag(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i >= 0 ? process.argv[i + 1] : undefined;
}
function has(name: string): boolean {
return process.argv.includes(name);
}
function deriveUrls(input: string): { http: string; ws: string } {
const u = input.replace(/\/$/, "");
if (u.startsWith("ws://")) return { ws: u, http: "http://" + u.slice(5) };
if (u.startsWith("wss://")) return { ws: u, http: "https://" + u.slice(6) };
if (u.startsWith("http://")) return { http: u, ws: "ws://" + u.slice(7) };
if (u.startsWith("https://")) return { http: u, ws: "wss://" + u.slice(8) };
return { http: "https://" + u, ws: "wss://" + u }; // bara host angiven -> anta TLS
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* -------------------------------- device-flöde ----------------------------- */
async function deviceFlow(
httpBase: string,
clientName: string,
): Promise<{ token: string; sessionId: string; clientName: string; user: string }> {
const codeRes = await fetch(`${httpBase}/api/device/code`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ clientName }),
});
if (!codeRes.ok) throw new Error(`device/code misslyckades (HTTP ${codeRes.status})`);
const code = (await codeRes.json()) as DeviceCodeResponse;
console.log("\n ┌─────────────────────────────────────────────────────────────");
console.log(" │ Öppna i webbläsaren, logga in och godkänn klienten:");
console.log(" │");
console.log(`${code.verificationUriComplete}`);
console.log(" │");
console.log(` │ Kod: ${code.userCode} (väntar på godkännande …)`);
console.log(" └─────────────────────────────────────────────────────────────\n");
const interval = Math.max(2, code.interval) * 1000;
const deadline = Date.now() + code.expiresIn * 1000;
while (Date.now() < deadline) {
await sleep(interval);
const tRes = await fetch(`${httpBase}/api/device/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ deviceCode: code.deviceCode }),
});
const t = (await tRes.json()) as DeviceTokenResponse;
if (t.status === "ready" && t.token && t.sessionId) {
return { token: t.token, sessionId: t.sessionId, clientName: t.clientName ?? clientName, user: t.user ?? "?" };
}
if (t.status === "denied") throw new Error("godkännandet nekades i webben");
if (t.status === "expired") throw new Error("koden gick ut innan godkännande");
// pending / slow_down -> fortsätt polla
}
throw new Error("tidsgräns nådd utan godkännande");
}
/* --------------------------------- connect --------------------------------- */
async function runConnect(): Promise<void> {
const input = flag("--server") || process.env.SERVER_URL || loadCreds()?.server;
if (!input) {
console.error("Ingen server angiven. Kör: agent-helm connect --server https://din-helm-server");
process.exit(1);
}
const { http: httpBase, ws: wsBase } = deriveUrls(input);
const clientName = flag("--name") || process.env.AGENT_TITLE || `${AGENT_CMD} @ ${os.hostname()}`;
let creds = loadCreds();
const sameServer = creds && deriveUrls(creds.server).ws === wsBase;
if (!creds || !sameServer || has("--login")) {
console.log(`[daemon] loggar in mot ${httpBase}`);
try {
const r = await deviceFlow(httpBase, clientName);
creds = { server: input, token: r.token, clientName: r.clientName, user: r.user, lastSessionId: r.sessionId };
saveCreds(creds);
console.log(`[daemon] ✓ ansluten som ${r.user}.`);
} catch (e) {
console.error(`[daemon] inloggning misslyckades: ${(e as Error).message}`);
process.exit(1);
}
}
// Sessionsval: --new -> ny parallell session; annars återuppta den senaste.
const isNew = has("--new") || !creds!.lastSessionId;
const sessionId = isNew ? randomUUID() : creds!.lastSessionId!;
creds!.lastSessionId = sessionId;
saveCreds(creds!);
console.log(`[daemon] ${isNew ? "ny" : "återupptar"} session ${sessionId.slice(0, 8)} (${clientName})`);
runAgent(wsBase, creds!.token, sessionId, clientName);
}
/* ----------------------------- agent + WS-loop ----------------------------- */
function runAgent(wsUrl: string, token: string, sessionId: string, clientName: string): void {
const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : [];
const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd();
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);
// Explicit hook-port om satt; annars en ledig (ephemeral) port så flera
// instanser på samma host inte krockar. Faktiska porten skickas till agenten.
const explicitHookPort = process.env.AGENT_HELM_HOOK_PORT ? Number(process.env.AGENT_HELM_HOOK_PORT) : 0;
const COLS = 100;
const ROWS = 30;
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let authFailed = false;
let pty: IPty | null = null;
let started = false;
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 ${wsUrl}`);
ws = new WebSocket(wsUrl);
ws.on("open", () => {
send({ type: "daemon:hello", token, sessionId, clientName, cols: COLS, rows: ROWS });
});
ws.on("message", (raw) => {
const msg = parseMessage<{ type: string; [k: string]: any }>(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 });
}
} else if (msg.type === "server:error") {
console.error(`[daemon] server: ${msg.message}`);
if (String(msg.message).toLowerCase().includes("token") || String(msg.message).includes("inloggad")) {
authFailed = true;
console.error("[daemon] klient-token avvisat — kör 'agent-helm connect --login' för att logga in på nytt.");
}
}
});
ws.on("close", () => {
if (authFailed) {
pty?.kill();
process.exit(1);
}
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 || authFailed) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 2000);
}
function startPty(hookPort: number): void {
if (started) return;
started = true;
pty = spawn(AGENT_CMD, AGENT_ARGS, {
name: "xterm-256color",
cols: COLS,
rows: ROWS,
cwd: AGENT_CWD,
env: { ...process.env, AGENT_HELM_HOOK_PORT: String(hookPort) } as Record<string, string>,
});
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid})`);
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);
});
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
connect();
}
function shutdown(): void {
pty?.kill();
process.exit(0);
}
// Lokal hook-endpoint (endast 127.0.0.1). Binds FÖRST så agenten får porten.
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));
};
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.on("error", (e: NodeJS.ErrnoException) => {
console.error(`[daemon] kunde inte binda hook-porten (${e.code ?? e.message}) — kör utan hook (default: ${HOOK_DEFAULT}).`);
startPty(explicitHookPort || HOOK_PORT);
});
hookServer.listen(explicitHookPort, "127.0.0.1", () => {
const addr = hookServer.address();
const port = typeof addr === "object" && addr ? addr.port : explicitHookPort;
console.log(`[daemon] hook-endpoint på 127.0.0.1:${port} (default vid ingen UI: ${HOOK_DEFAULT})`);
startPty(port);
});
}
/* --------------------------------- hook-läge ------------------------------- */
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);
});
}
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 = {};
}
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;
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);
}
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" });
}
});
},
);
req.on("error", () => resolve({ decision: "allow" }));
req.setTimeout(180000, () => {
req.destroy();
resolve({ decision: "allow" });
});
req.write(body);
req.end();
});
}
/* --------------------------------- dispatch -------------------------------- */
// VIKTIGT: körs sist, efter att alla module-level const (CONFIG_DIR/CRED_FILE …)
// och funktioner initierats. Annars skulle runConnect:s synkrona del (fram till
// första await) köras under modul-init och läsa dessa const i TDZ → loadCreds()
// får en undefined path → returnerar null → onödigt nytt device-flöde.
const cmd = process.argv[2];
if (cmd === "hook") void runHookMode();
else if (cmd === "logout") logout();
else if (cmd === "whoami") whoami();
else void runConnect();