auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
All checks were successful
build-and-push / build (push) Successful in 18s
release-client / build-release (push) Successful in 48s

Ersätter den delade AGENT_HELM_TOKEN med en riktig auth-modell:

- Webben loggar in med lokala konton ELLER OIDC (Authentik/Keycloak/…), valt
  per server. Inloggning ger en HMAC-signerad cookie; WS autentiseras via cookien.
- Klienten (daemon) kopplas via device-flödet (RFC 8628-likt): `agent-helm connect`
  skriver ut en URL, du loggar in + godkänner i webben, och får ett klient-token
  som identifierar din användare. Token sparas i ~/.config/agent-helm/.
- Sessioner är per användare (web ser bara sina egna; admin ser alla). Flera
  klient-instanser = flera parallella sessioner; --new tvingar ny, annars resume.
- Identitet är utbytbar (lokal/OIDC) men device-flödet + sessionerna är alltid
  agent-helms egna → funkar för vem som helst som hostar detta, med eller utan IdP.
- Bootstrap-admin via env (engångs) eller first-run-setup i UI:t. Klient-creds
  kan listas/återkallas; minimal admin-UI (skapa användare, konfigurera OIDC).

Nya filer: server/store.ts (JSON-store, scrypt, cookies), server/oidc.ts
(auth-code+PKCE), web/lib/api.ts. Verifierat end-to-end lokalt (login → device
→ approve → daemon-WS → web ser sessionen; web utan cookie nekas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U68YyHsxU91ecsen84WQV4
This commit is contained in:
2026-06-29 01:21:29 +02:00
parent 98c4d13360
commit 94259b3456
12 changed files with 1704 additions and 403 deletions

View File

@@ -1,36 +1,313 @@
/**
* agent-helm daemon ("appen") + hook-läge.
* agent-helm daemon ("klienten") + hook-läge.
*
* 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.
* 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.
*
* 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.
* 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 { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { DaemonMessage, ServerToDaemon } from "@agent-helm/shared";
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";
if (process.argv[2] === "hook") {
void runHookMode();
} else {
runDaemon();
const cmd = process.argv[2];
if (cmd === "hook") void runHookMode();
else if (cmd === "logout") logout();
else if (cmd === "whoami") whoami();
else void runConnect();
/* ------------------------------ creds-hantering ---------------------------- */
interface Creds {
server: string;
token: string;
clientName: string;
user: string;
lastSessionId?: string;
}
/* --------------------------------- hook-läge -------------------------------- */
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);
const COLS = 100;
const ROWS = 30;
const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, {
name: "xterm-256color",
cols: COLS,
rows: ROWS,
cwd: AGENT_CWD,
env: { ...process.env, AGENT_HELM_HOOK_PORT: String(HOOK_PORT) } as Record<string, string>,
});
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid})`);
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let authFailed = 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);
}
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).
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.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();
}
/* --------------------------------- hook-läge ------------------------------- */
function readStdin(): Promise<string> {
return new Promise((resolve) => {
@@ -38,7 +315,7 @@ function readStdin(): Promise<string> {
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
setTimeout(() => resolve(data), 1500);
});
}
@@ -50,12 +327,9 @@ async function runHookMode(): Promise<void> {
} 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;
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") {
@@ -89,7 +363,6 @@ function askDaemon(toolName: string, toolInput: unknown): Promise<Decision> {
});
},
);
// Ingen daemon igång / fel -> tillåt (graceful: agenten funkar som utan hook).
req.on("error", () => resolve({ decision: "allow" }));
req.setTimeout(180000, () => {
req.destroy();
@@ -99,140 +372,3 @@ function askDaemon(toolName: string, toolInput: unknown): Promise<Decision> {
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();
}