BeforeTool-hook + kort-vy (strukturerade godkännanden) + release på master-push
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:
@@ -13,6 +13,11 @@ AGENT_CMD=gemini
|
||||
# AGENT_CWD=
|
||||
# AGENT_TITLE=
|
||||
|
||||
# --- hook (valfritt: strukturerade verktygsgodkännanden via BeforeTool) ---
|
||||
# AGENT_HELM_HOOK_PORT=8788 # lokal 127.0.0.1-endpoint som `agent-helm hook` anropar
|
||||
# AGENT_HELM_HOOK_DEFAULT=allow # beslut när ingen UI är ansluten (allow|deny)
|
||||
# AGENT_HELM_HOOK_TIMEOUT=120000 # ms att vänta på beslut innan default används
|
||||
|
||||
# --- frontend (Vite läser bara VITE_*-prefix) ---
|
||||
VITE_SERVER_WS=ws://localhost:8787
|
||||
VITE_AGENT_HELM_TOKEN=change-me
|
||||
|
||||
@@ -6,12 +6,13 @@ name: release-client
|
||||
# dem som assets på en Gitea-release.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
branches: [master, main] # varje master-push -> rullande 'latest'-release
|
||||
tags: ["v*"] # vX.Y.Z -> versionerad release
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release-tag (t.ex. v0.1.0)"
|
||||
required: true
|
||||
description: "Release-tag (lämna tom för 'latest')"
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build-release:
|
||||
@@ -23,7 +24,12 @@ jobs:
|
||||
- name: Bestäm tag
|
||||
id: t
|
||||
run: |
|
||||
if [ -n "${{ inputs.tag }}" ]; then echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"; else echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"; fi
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/*) T="$GITHUB_REF_NAME" ;;
|
||||
*) T="${{ inputs.tag }}"; [ -z "$T" ] && T="latest" ;;
|
||||
esac
|
||||
echo "tag=$T" >> "$GITHUB_OUTPUT"
|
||||
echo "bygger release: $T"
|
||||
|
||||
- name: Bundla daemonen
|
||||
run: |
|
||||
|
||||
@@ -94,8 +94,10 @@ curl -s http://localhost:5000/v2/agent-helm-server/tags/list
|
||||
containern kör som uid 1000 = `brasse` så filerna blir korrekt ägda.
|
||||
2. Ny stack i Dockge, namn `agent-helm`.
|
||||
3. Klistra in `deploy/agent-helm.compose.yaml`.
|
||||
4. I stackens **env**: `AGENT_HELM_TOKEN=<lång-slumpad-hemlighet>`
|
||||
(generera t.ex. `openssl rand -hex 32`).
|
||||
4. I stackens **env**: `AGENT_HELM_TOKEN=<lång slumpad hemlighet>`
|
||||
— en stark slumpad sträng (≥ 32 byte). Förvaras **bara** i stackens `.env`
|
||||
på Pi5, aldrig i git. Det är denna token som skyddar åtkomsten till dina
|
||||
sessioner, så håll den hemlig.
|
||||
5. Deploy. Kolla i Dockge att containern `agent-helm` är *running*.
|
||||
|
||||
---
|
||||
@@ -164,6 +166,34 @@ systemctl --user daemon-reload && systemctl --user enable --now agent-helm
|
||||
```
|
||||
Klienten ansluter till `wss://rcai.brasse-pc.eu` och dyker upp som en session i UI:t.
|
||||
|
||||
## Strukturerade godkännanden (BeforeTool-hook + kort-vy)
|
||||
|
||||
Utöver fallback-tangentbordet kan agentens verktygsanrop visas som **godkännande-kort**
|
||||
i UI:t (Tillåt/Neka). Det sker via en `BeforeTool`-hook i Gemini som anropar
|
||||
`agent-helm hook` — samma binär, så inget extra beroende.
|
||||
|
||||
**Aktivera** genom att lägga till hooken i `~/.gemini/settings.json` på maskinen där
|
||||
klienten kör (justera vid behov efter din Gemini-versions hook-schema):
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [
|
||||
{ "hooks": [{ "type": "command", "command": "agent-helm hook" }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
Daemonen skickar automatiskt `AGENT_HELM_HOOK_PORT` till agentens miljö, så
|
||||
`agent-helm hook` hittar den lokala endpointen själv.
|
||||
|
||||
**Beteende:**
|
||||
- UI anslutet → verktygsanropet visas som kort, agenten väntar på ditt beslut.
|
||||
- Ingen UI / servern onåbar → `AGENT_HELM_HOOK_DEFAULT` (default `allow` — agenten
|
||||
fungerar som utan hook). Sätt `AGENT_HELM_HOOK_DEFAULT=deny` för strikt läge.
|
||||
- Inget svar inom `AGENT_HELM_HOOK_TIMEOUT` (ms) → default-beslutet används.
|
||||
|
||||
Den lokala hook-endpointen binder **endast** `127.0.0.1` och är aldrig nåbar utifrån.
|
||||
|
||||
## Uppdatera till senaste
|
||||
|
||||
1. `git push master` → runnern bygger + pushar `:latest`.
|
||||
|
||||
@@ -26,10 +26,8 @@ ASSET="agent-helm-linux-$ARCH"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "agent-helm: curl krävs"; exit 1; }
|
||||
|
||||
# --- senaste release-tag ---
|
||||
echo "agent-helm: slår upp senaste release ..."
|
||||
TAG=$(curl -fsSL "$API/releases/latest" | tr ',' '\n' | grep -m1 '"tag_name"' | cut -d'"' -f4)
|
||||
[ -n "${TAG:-}" ] || { echo "agent-helm: hittade ingen release på $BASE"; exit 1; }
|
||||
# --- version: 'latest' (rullande master-bygge) om inget annat anges ---
|
||||
TAG="${AGENT_HELM_VERSION:-latest}"
|
||||
URL="$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
|
||||
# --- bin-katalog: /usr/local/bin om root/skrivbar, annars ~/.local/bin (ingen sudo).
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -146,6 +146,15 @@ function handleDaemonMessage(sessionId: string, text: string): void {
|
||||
sendWeb(sub, { type: "server:exit", sessionId, code: msg.code });
|
||||
}
|
||||
broadcastSessions();
|
||||
} else if (msg.type === "daemon:approval") {
|
||||
const out: ServerToWeb = {
|
||||
type: "server:approval",
|
||||
sessionId,
|
||||
approvalId: msg.approvalId,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
};
|
||||
for (const sub of session.subscribers) sendWeb(sub, out);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +175,29 @@ function handleWebMessage(ws: WebSocket, text: string): void {
|
||||
} else if (msg.type === "web:input") {
|
||||
const session = sessions.get(msg.sessionId);
|
||||
if (!session?.daemon || session.daemon.readyState !== WebSocket.OPEN) return;
|
||||
// Strukturerade hook-svar går INTE här — bara terminal-input (text/tangenter).
|
||||
// Terminal-input (text/tangenter) -> pty. Hook-svar går via web:approval-decision.
|
||||
session.daemon.send(JSON.stringify({ type: "server:input", data: msg.data }));
|
||||
} else if (msg.type === "web:approval-decision") {
|
||||
const session = sessions.get(msg.sessionId);
|
||||
if (!session) return;
|
||||
if (session.daemon && session.daemon.readyState === WebSocket.OPEN) {
|
||||
session.daemon.send(
|
||||
JSON.stringify({
|
||||
type: "server:approval-decision",
|
||||
approvalId: msg.approvalId,
|
||||
decision: msg.decision,
|
||||
reason: msg.reason,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Dölj kortet hos alla andra klienter som ser samma session.
|
||||
const resolved: ServerToWeb = {
|
||||
type: "server:approval-resolved",
|
||||
sessionId: msg.sessionId,
|
||||
approvalId: msg.approvalId,
|
||||
decision: msg.decision,
|
||||
};
|
||||
for (const sub of session.subscribers) sendWeb(sub, resolved);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,15 @@ export interface DaemonExit {
|
||||
code: number;
|
||||
}
|
||||
|
||||
export type DaemonMessage = DaemonHello | DaemonOutput | DaemonExit;
|
||||
/** Agenten vill köra ett verktyg (via BeforeTool-hooken) och vill ha godkännande. */
|
||||
export interface DaemonApproval {
|
||||
type: "daemon:approval";
|
||||
approvalId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
}
|
||||
|
||||
export type DaemonMessage = DaemonHello | DaemonOutput | DaemonExit | DaemonApproval;
|
||||
|
||||
/* ----------------------------- Server -> Daemon ---------------------------- */
|
||||
|
||||
@@ -47,7 +55,15 @@ export interface ServerInput {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type ServerToDaemon = ServerInput;
|
||||
/** Beslut på en hook-förfrågan, vidarebefordrat till daemonen som äger sessionen. */
|
||||
export interface ServerApprovalDecision {
|
||||
type: "server:approval-decision";
|
||||
approvalId: string;
|
||||
decision: "allow" | "deny";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type ServerToDaemon = ServerInput | ServerApprovalDecision;
|
||||
|
||||
/* ---------------------------- Frontend -> Server --------------------------- */
|
||||
|
||||
@@ -68,7 +84,16 @@ export interface WebInput {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type WebMessage = WebHello | WebSubscribe | WebInput;
|
||||
/** Användarens svar på ett godkännande-kort. */
|
||||
export interface WebApprovalDecision {
|
||||
type: "web:approval-decision";
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
decision: "allow" | "deny";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type WebMessage = WebHello | WebSubscribe | WebInput | WebApprovalDecision;
|
||||
|
||||
/* ---------------------------- Server -> Frontend --------------------------- */
|
||||
|
||||
@@ -106,13 +131,32 @@ export interface ServerError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Strukturerad godkännande-förfrågan (från en BeforeTool-hook) -> visas som kort. */
|
||||
export interface ServerApproval {
|
||||
type: "server:approval";
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
}
|
||||
|
||||
/** En förfrågan har besvarats (av dig eller en annan klient) -> dölj kortet. */
|
||||
export interface ServerApprovalResolved {
|
||||
type: "server:approval-resolved";
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
decision: "allow" | "deny";
|
||||
}
|
||||
|
||||
export type ServerToWeb =
|
||||
| ServerWelcome
|
||||
| ServerSessions
|
||||
| ServerSnapshot
|
||||
| ServerOutput
|
||||
| ServerSessionExit
|
||||
| ServerError;
|
||||
| ServerError
|
||||
| ServerApproval
|
||||
| ServerApprovalResolved;
|
||||
|
||||
/* --------------------------------- Helpers --------------------------------- */
|
||||
|
||||
|
||||
@@ -14,11 +14,25 @@ const url = ref(localStorage.getItem("helm.url") ?? defaultUrl);
|
||||
const token = ref(localStorage.getItem("helm.token") ?? defaultToken);
|
||||
|
||||
const client = createHelmClient();
|
||||
const { connected, connecting, error, sessions } = client;
|
||||
const { connected, connecting, error, sessions, approvals } = client;
|
||||
|
||||
const selected = ref<string | null>(null);
|
||||
const sidebarOpen = ref(true);
|
||||
|
||||
function sessionTitle(id: string): string {
|
||||
return sessions.value.find((s) => s.sessionId === id)?.title ?? id.slice(0, 8);
|
||||
}
|
||||
function fmtInput(v: unknown): string {
|
||||
if (v == null || v === "") return "";
|
||||
let s: string;
|
||||
try {
|
||||
s = typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
s = String(v);
|
||||
}
|
||||
return s.length > 600 ? `${s.slice(0, 600)} …` : s;
|
||||
}
|
||||
|
||||
const activeMeta = computed(
|
||||
() => sessions.value.find((s) => s.sessionId === selected.value) ?? null,
|
||||
);
|
||||
@@ -89,6 +103,22 @@ function select(id: string): void {
|
||||
/>
|
||||
<div v-else class="placeholder">Välj en session till vänster</div>
|
||||
</main>
|
||||
|
||||
<!-- Strukturerade godkännanden (BeforeTool-hook) -->
|
||||
<div v-if="approvals.length" class="approvals">
|
||||
<div v-for="a in approvals" :key="a.approvalId" class="approval-card">
|
||||
<div class="ac-head">
|
||||
<span class="ac-badge">godkännande</span>
|
||||
<span class="ac-session">{{ sessionTitle(a.sessionId) }}</span>
|
||||
</div>
|
||||
<div class="ac-tool">{{ a.toolName }}</div>
|
||||
<pre v-if="fmtInput(a.toolInput)" class="ac-input">{{ fmtInput(a.toolInput) }}</pre>
|
||||
<div class="ac-actions">
|
||||
<button class="deny" @click="client.approve(a.sessionId, a.approvalId, 'deny')">Neka</button>
|
||||
<button class="allow" @click="client.approve(a.sessionId, a.approvalId, 'allow')">Tillåt</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -199,6 +229,66 @@ function select(id: string): void {
|
||||
}
|
||||
.placeholder { margin: auto; color: var(--muted); }
|
||||
|
||||
/* Godkännande-kort (BeforeTool-hook) */
|
||||
.approvals {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: min(380px, calc(100vw - 2rem));
|
||||
}
|
||||
.approval-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 0.7rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.ac-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
|
||||
.ac-badge {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
color: var(--accent);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.ac-session { font-size: 0.75rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ac-tool { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.35rem; word-break: break-word; }
|
||||
.ac-input {
|
||||
margin: 0 0 0.6rem;
|
||||
max-height: 9rem;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
color: var(--text);
|
||||
}
|
||||
.ac-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
||||
.ac-actions button {
|
||||
flex: 1;
|
||||
padding: 0.55rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.ac-actions .allow { background: var(--green); color: #06121f; border-color: var(--green); }
|
||||
.ac-actions .deny { background: var(--panel-2); color: var(--red); }
|
||||
.ac-actions button:active { opacity: 0.8; }
|
||||
@media (max-width: 720px) {
|
||||
.approvals { left: 0.6rem; right: 0.6rem; bottom: 0.6rem; width: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.sidebar {
|
||||
position: absolute;
|
||||
|
||||
@@ -8,11 +8,20 @@ export interface HelmHandlers {
|
||||
onExit?: (sessionId: string, code: number) => void;
|
||||
}
|
||||
|
||||
/** En väntande hook-förfrågan (verktygsgodkännande) -> visas som kort. */
|
||||
export interface ApprovalCard {
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
toolName: string;
|
||||
toolInput: unknown;
|
||||
}
|
||||
|
||||
export function createHelmClient() {
|
||||
const connected = ref(false);
|
||||
const connecting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const sessions = ref<SessionMeta[]>([]);
|
||||
const approvals = ref<ApprovalCard[]>([]);
|
||||
const handlers: HelmHandlers = {};
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
@@ -55,6 +64,15 @@ export function createHelmClient() {
|
||||
);
|
||||
handlers.onExit?.(msg.sessionId, msg.code);
|
||||
break;
|
||||
case "server:approval":
|
||||
approvals.value = [
|
||||
...approvals.value,
|
||||
{ sessionId: msg.sessionId, approvalId: msg.approvalId, toolName: msg.toolName, toolInput: msg.toolInput },
|
||||
];
|
||||
break;
|
||||
case "server:approval-resolved":
|
||||
approvals.value = approvals.value.filter((a) => a.approvalId !== msg.approvalId);
|
||||
break;
|
||||
case "server:error":
|
||||
error.value = msg.message;
|
||||
connecting.value = false;
|
||||
@@ -83,8 +101,15 @@ export function createHelmClient() {
|
||||
ws = null;
|
||||
connected.value = false;
|
||||
}
|
||||
function approve(sessionId: string, approvalId: string, decision: "allow" | "deny"): void {
|
||||
send({ type: "web:approval-decision", sessionId, approvalId, decision });
|
||||
approvals.value = approvals.value.filter((a) => a.approvalId !== approvalId);
|
||||
}
|
||||
|
||||
return { connected, connecting, error, sessions, handlers, connect, disconnect, subscribe, sendInput };
|
||||
return {
|
||||
connected, connecting, error, sessions, approvals, handlers,
|
||||
connect, disconnect, subscribe, sendInput, approve,
|
||||
};
|
||||
}
|
||||
|
||||
export type HelmClient = ReturnType<typeof createHelmClient>;
|
||||
|
||||
Reference in New Issue
Block a user