BeforeTool-hook + kort-vy (strukturerade godkännanden) + release på master-push
All checks were successful
build-and-push / build (push) Successful in 22s
release-client / build-release (push) Successful in 48s

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:
2026-06-28 18:03:12 +02:00
parent a06a02927c
commit 98c4d13360
9 changed files with 460 additions and 97 deletions

View File

@@ -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;

View File

@@ -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>;