/** * 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 { 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 | null = null; let authFailed = false; let pty: IPty | null = null; let started = false; const pending = new Map 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, }); 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 { 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 { const raw = await readStdin(); let payload: Record = {}; try { payload = raw ? (JSON.parse(raw) as Record) : {}; } catch { payload = {}; } const p = payload as Record; 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 { 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();