auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user