diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift index 4d6541287..6cde4ae79 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift @@ -46,6 +46,7 @@ private struct ExecHostRequest: Codable { var needsScreenRecording: Bool? var agentId: String? var sessionKey: String? + var approvalDecision: ExecApprovalDecision? } private struct ExecHostRunResult: Codable { @@ -328,8 +329,21 @@ private enum ExecHostExecutor { return false }() - var approvedByAsk = false - if requiresAsk { + let approvalDecision = request.approvalDecision + if approvalDecision == .deny { + return ExecHostResponse( + type: "exec-res", + id: UUID().uuidString, + ok: false, + payload: nil, + error: ExecHostError( + code: "UNAVAILABLE", + message: "SYSTEM_RUN_DENIED: user denied", + reason: "user-denied")) + } + + var approvedByAsk = approvalDecision != nil + if requiresAsk, approvalDecision == nil { let decision = ExecApprovalsPromptPresenter.prompt( ExecApprovalPromptRequest( command: displayCommand, @@ -364,6 +378,13 @@ private enum ExecHostExecutor { } } + if approvalDecision == .allowAlways, security == .allowlist { + let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? "" + if !pattern.isEmpty { + ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern) + } + } + if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk { return ExecHostResponse( type: "exec-res", diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 2950e4407..47d5c17b3 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -477,6 +477,7 @@ export function createExecTool( (hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch); let approvedByAsk = false; + let approvalDecision: "allow-once" | "allow-always" | null = null; if (requiresAsk) { const decisionResult = (await callGatewayTool( "exec.approval.request", @@ -504,20 +505,24 @@ export function createExecTool( if (!decision) { if (askFallback === "full") { approvedByAsk = true; + approvalDecision = "allow-once"; } else if (askFallback === "allowlist") { if (!allowlistMatch) { throw new Error("exec denied: approval required (approval UI not available)"); } approvedByAsk = true; + approvalDecision = "allow-once"; } else { throw new Error("exec denied: approval required (approval UI not available)"); } } if (decision === "allow-once") { approvedByAsk = true; + approvalDecision = "allow-once"; } if (decision === "allow-always") { approvedByAsk = true; + approvalDecision = "allow-always"; if (hostSecurity === "allowlist") { const pattern = resolution?.resolvedPath ?? @@ -556,6 +561,7 @@ export function createExecTool( agentId: defaults?.agentId, sessionKey: defaults?.sessionKey, approved: approvedByAsk, + approvalDecision: approvalDecision ?? undefined, }, idempotencyKey: crypto.randomUUID(), }; diff --git a/src/infra/exec-host.ts b/src/infra/exec-host.ts index 73176788b..904b6503a 100644 --- a/src/infra/exec-host.ts +++ b/src/infra/exec-host.ts @@ -10,6 +10,7 @@ export type ExecHostRequest = { needsScreenRecording?: boolean | null; agentId?: string | null; sessionKey?: string | null; + approvalDecision?: "allow-once" | "allow-always" | null; }; export type ExecHostRunResult = { diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 1c6c4a4ba..fd8dad184 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -50,6 +50,7 @@ type SystemRunParams = { agentId?: string | null; sessionKey?: string | null; approved?: boolean | null; + approvalDecision?: string | null; }; type SystemWhichParams = { @@ -561,6 +562,10 @@ async function handleInvoke( const useMacAppExec = process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed); if (useMacAppExec) { + const approvalDecision = + params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" + ? params.approvalDecision + : null; const execRequest: ExecHostRequest = { command: argv, rawCommand: rawCommand || null, @@ -570,6 +575,7 @@ async function handleInvoke( needsScreenRecording: params.needsScreenRecording ?? null, agentId: agentId ?? null, sessionKey: sessionKey ?? null, + approvalDecision, }; const response = await runViaMacAppExecHost({ approvals, request: execRequest }); if (!response) { @@ -660,7 +666,11 @@ async function handleInvoke( ask === "always" || (ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow); - const approvedByAsk = params.approved === true; + const approvalDecision = + params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always" + ? params.approvalDecision + : null; + const approvedByAsk = approvalDecision !== null || params.approved === true; if (requiresAsk && !approvedByAsk) { await sendNodeEvent( client, @@ -679,7 +689,7 @@ async function handleInvoke( }); return; } - if (approvedByAsk && security === "allowlist") { + if (approvalDecision === "allow-always" && security === "allowlist") { const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? ""; if (pattern) addAllowlistEntry(approvals.file, agentId, pattern); } diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index e04492531..435fd59bc 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1181,3 +1181,96 @@ border-radius: 10px; image-rendering: pixelated; } + +.exec-approval-overlay { + position: fixed; + inset: 0; + background: rgba(8, 12, 18, 0.7); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 200; +} + +.exec-approval-card { + width: min(560px, 100%); + background: var(--panel-strong); + border: 1px solid var(--border-strong); + border-radius: 18px; + padding: 20px; + box-shadow: 0 28px 60px rgba(0, 0, 0, 0.35); + animation: rise 0.25s ease; +} + +.exec-approval-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.exec-approval-title { + font-family: var(--font-display); + font-size: 14px; + letter-spacing: 0.8px; + text-transform: uppercase; +} + +.exec-approval-sub { + color: var(--muted); + font-size: 12px; +} + +.exec-approval-queue { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 999px; + padding: 4px 10px; +} + +.exec-approval-command { + margin-top: 12px; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--border); + border-radius: 12px; + word-break: break-word; + white-space: pre-wrap; +} + +.exec-approval-meta { + margin-top: 12px; + display: grid; + gap: 6px; + font-size: 12px; + color: var(--muted); +} + +.exec-approval-meta-row { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.exec-approval-meta-row span:last-child { + color: var(--text); + font-family: var(--mono); +} + +.exec-approval-error { + margin-top: 10px; + font-size: 12px; + color: var(--danger); +} + +.exec-approval-actions { + margin-top: 16px; + display: flex; + flex-wrap: wrap; + gap: 10px; +} diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 79f105c09..f300e0b37 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -16,7 +16,14 @@ import { setLastActiveSessionKey, } from "./app-settings"; import { handleChatEvent, type ChatEventPayload } from "./controllers/chat"; +import { + addExecApproval, + parseExecApprovalRequested, + parseExecApprovalResolved, + removeExecApproval, +} from "./controllers/exec-approval"; import type { ClawdbotApp } from "./app"; +import type { ExecApprovalRequest } from "./controllers/exec-approval"; type GatewayHost = { settings: UiSettings; @@ -34,6 +41,8 @@ type GatewayHost = { debugHealth: HealthSnapshot | null; sessionKey: string; chatRunId: string | null; + execApprovalQueue: ExecApprovalRequest[]; + execApprovalError: string | null; }; type SessionDefaultsSnapshot = { @@ -94,6 +103,8 @@ export function connectGateway(host: GatewayHost) { host.lastError = null; host.hello = null; host.connected = false; + host.execApprovalQueue = []; + host.execApprovalError = null; host.client?.stop(); host.client = new GatewayBrowserClient({ @@ -175,6 +186,26 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) { if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") { void loadDevices(host as unknown as ClawdbotApp, { quiet: true }); } + + if (evt.event === "exec.approval.requested") { + const entry = parseExecApprovalRequested(evt.payload); + if (entry) { + host.execApprovalQueue = addExecApproval(host.execApprovalQueue, entry); + host.execApprovalError = null; + const delay = Math.max(0, entry.expiresAtMs - Date.now() + 500); + window.setTimeout(() => { + host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, entry.id); + }, delay); + } + return; + } + + if (evt.event === "exec.approval.resolved") { + const resolved = parseExecApprovalResolved(evt.payload); + if (resolved) { + host.execApprovalQueue = removeExecApproval(host.execApprovalQueue, resolved.id); + } + } } export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index f45990c35..02f529618 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -38,6 +38,7 @@ import { renderLogs } from "./views/logs"; import { renderNodes } from "./views/nodes"; import { renderOverview } from "./views/overview"; import { renderSessions } from "./views/sessions"; +import { renderExecApprovalPrompt } from "./views/exec-approval"; import { approveDevicePairing, loadDevices, @@ -518,6 +519,7 @@ export function renderApp(state: AppViewState) { }) : nothing} + ${renderExecApprovalPrompt(state)} `; } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 21f8e7d1c..f5f1ce95a 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -25,6 +25,7 @@ import type { ExecApprovalsSnapshot, } from "./controllers/exec-approvals"; import type { DevicePairingList } from "./controllers/devices"; +import type { ExecApprovalRequest } from "./controllers/exec-approval"; export type AppViewState = { settings: UiSettings; @@ -60,6 +61,9 @@ export type AppViewState = { execApprovalsSelectedAgent: string | null; execApprovalsTarget: "gateway" | "node"; execApprovalsTargetNodeId: string | null; + execApprovalQueue: ExecApprovalRequest[]; + execApprovalBusy: boolean; + execApprovalError: string | null; configLoading: boolean; configRaw: string; configValid: boolean | null; @@ -137,6 +141,7 @@ export type AppViewState = { handleWhatsAppLogout: () => Promise; handleChannelConfigSave: () => Promise; handleChannelConfigReload: () => Promise; + handleExecApprovalDecision: (decision: "allow-once" | "allow-always" | "deny") => Promise; handleConfigLoad: () => Promise; handleConfigSave: () => Promise; handleConfigApply: () => Promise; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 2d0561c15..82ce65d06 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -29,6 +29,7 @@ import type { ExecApprovalsSnapshot, } from "./controllers/exec-approvals"; import type { DevicePairingList } from "./controllers/devices"; +import type { ExecApprovalRequest } from "./controllers/exec-approval"; import { resetToolStream as resetToolStreamInternal, toggleToolOutput as toggleToolOutputInternal, @@ -120,6 +121,9 @@ export class ClawdbotApp extends LitElement { @state() execApprovalsSelectedAgent: string | null = null; @state() execApprovalsTarget: "gateway" | "node" = "gateway"; @state() execApprovalsTargetNodeId: string | null = null; + @state() execApprovalQueue: ExecApprovalRequest[] = []; + @state() execApprovalBusy = false; + @state() execApprovalError: string | null = null; @state() configLoading = false; @state() configRaw = "{\n}\n"; @@ -365,6 +369,24 @@ export class ClawdbotApp extends LitElement { await handleChannelConfigReloadInternal(this); } + async handleExecApprovalDecision(decision: "allow-once" | "allow-always" | "deny") { + const active = this.execApprovalQueue[0]; + if (!active || !this.client || this.execApprovalBusy) return; + this.execApprovalBusy = true; + this.execApprovalError = null; + try { + await this.client.request("exec.approval.resolve", { + id: active.id, + decision, + }); + this.execApprovalQueue = this.execApprovalQueue.filter((entry) => entry.id !== active.id); + } catch (err) { + this.execApprovalError = `Exec approval failed: ${String(err)}`; + } finally { + this.execApprovalBusy = false; + } + } + // Sidebar handlers for tool output viewing handleOpenSidebar(content: string) { if (this.sidebarCloseTimer != null) { diff --git a/ui/src/ui/controllers/exec-approval.ts b/ui/src/ui/controllers/exec-approval.ts new file mode 100644 index 000000000..60e0fc7a0 --- /dev/null +++ b/ui/src/ui/controllers/exec-approval.ts @@ -0,0 +1,85 @@ +export type ExecApprovalRequestPayload = { + command: string; + cwd?: string | null; + host?: string | null; + security?: string | null; + ask?: string | null; + agentId?: string | null; + resolvedPath?: string | null; + sessionKey?: string | null; +}; + +export type ExecApprovalRequest = { + id: string; + request: ExecApprovalRequestPayload; + createdAtMs: number; + expiresAtMs: number; +}; + +export type ExecApprovalResolved = { + id: string; + decision?: string | null; + resolvedBy?: string | null; + ts?: number | null; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function parseExecApprovalRequested(payload: unknown): ExecApprovalRequest | null { + if (!isRecord(payload)) return null; + const id = typeof payload.id === "string" ? payload.id.trim() : ""; + const request = payload.request; + if (!id || !isRecord(request)) return null; + const command = typeof request.command === "string" ? request.command.trim() : ""; + if (!command) return null; + const createdAtMs = typeof payload.createdAtMs === "number" ? payload.createdAtMs : 0; + const expiresAtMs = typeof payload.expiresAtMs === "number" ? payload.expiresAtMs : 0; + if (!createdAtMs || !expiresAtMs) return null; + return { + id, + request: { + command, + cwd: typeof request.cwd === "string" ? request.cwd : null, + host: typeof request.host === "string" ? request.host : null, + security: typeof request.security === "string" ? request.security : null, + ask: typeof request.ask === "string" ? request.ask : null, + agentId: typeof request.agentId === "string" ? request.agentId : null, + resolvedPath: typeof request.resolvedPath === "string" ? request.resolvedPath : null, + sessionKey: typeof request.sessionKey === "string" ? request.sessionKey : null, + }, + createdAtMs, + expiresAtMs, + }; +} + +export function parseExecApprovalResolved(payload: unknown): ExecApprovalResolved | null { + if (!isRecord(payload)) return null; + const id = typeof payload.id === "string" ? payload.id.trim() : ""; + if (!id) return null; + return { + id, + decision: typeof payload.decision === "string" ? payload.decision : null, + resolvedBy: typeof payload.resolvedBy === "string" ? payload.resolvedBy : null, + ts: typeof payload.ts === "number" ? payload.ts : null, + }; +} + +export function pruneExecApprovalQueue(queue: ExecApprovalRequest[]): ExecApprovalRequest[] { + const now = Date.now(); + return queue.filter((entry) => entry.expiresAtMs > now); +} + +export function addExecApproval( + queue: ExecApprovalRequest[], + entry: ExecApprovalRequest, +): ExecApprovalRequest[] { + const next = pruneExecApprovalQueue(queue).filter((item) => item.id !== entry.id); + next.push(entry); + return next; +} + +export function removeExecApproval(queue: ExecApprovalRequest[], id: string): ExecApprovalRequest[] { + return pruneExecApprovalQueue(queue).filter((entry) => entry.id !== id); +} diff --git a/ui/src/ui/gateway.ts b/ui/src/ui/gateway.ts index f7bce2f7c..37ab5e2cd 100644 --- a/ui/src/ui/gateway.ts +++ b/ui/src/ui/gateway.ts @@ -127,7 +127,7 @@ export class GatewayBrowserClient { this.connectTimer = null; } const deviceIdentity = await loadOrCreateDeviceIdentity(); - const scopes = ["operator.admin"]; + const scopes = ["operator.admin", "operator.approvals", "operator.pairing"]; const role = "operator"; const storedToken = loadDeviceAuthToken({ deviceId: deviceIdentity.deviceId, diff --git a/ui/src/ui/views/exec-approval.ts b/ui/src/ui/views/exec-approval.ts new file mode 100644 index 000000000..548d56683 --- /dev/null +++ b/ui/src/ui/views/exec-approval.ts @@ -0,0 +1,78 @@ +import { html, nothing } from "lit"; + +import type { AppViewState } from "../app-view-state"; + +function formatRemaining(ms: number): string { + const remaining = Math.max(0, ms); + const totalSeconds = Math.floor(remaining / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + return `${hours}h`; +} + +function renderMetaRow(label: string, value?: string | null) { + if (!value) return nothing; + return html`
${label}${value}
`; +} + +export function renderExecApprovalPrompt(state: AppViewState) { + const active = state.execApprovalQueue[0]; + if (!active) return nothing; + const request = active.request; + const remainingMs = active.expiresAtMs - Date.now(); + const remaining = remainingMs > 0 ? `expires in ${formatRemaining(remainingMs)}` : "expired"; + const queueCount = state.execApprovalQueue.length; + return html` + + `; +}