From 4de3c3a028ae34c56ac5bfe7cfe6dffe7d19b2fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 08:54:34 +0000 Subject: [PATCH] feat: add exec approvals editor in control ui and mac app --- .../Clawdbot/SystemRunSettingsView.swift | 157 ++++-- docs/tools/exec-approvals.md | 10 + docs/web/control-ui.md | 1 + src/gateway/protocol/index.ts | 14 + src/gateway/protocol/schema.ts | 1 + src/gateway/protocol/schema/exec-approvals.ts | 72 +++ .../protocol/schema/protocol-schemas.ts | 8 + src/gateway/protocol/schema/types.ts | 8 + src/gateway/server-methods-list.ts | 2 + src/gateway/server-methods.ts | 2 + src/gateway/server-methods/exec-approvals.ts | 157 ++++++ src/infra/exec-approvals.ts | 49 +- ui/src/ui/app-render.ts | 21 + ui/src/ui/app-settings.ts | 2 + ui/src/ui/app-view-state.ts | 11 +- ui/src/ui/app.ts | 10 + ui/src/ui/controllers/exec-approvals.ts | 123 +++++ ui/src/ui/views/nodes.ts | 513 ++++++++++++++++++ 18 files changed, 1116 insertions(+), 45 deletions(-) create mode 100644 src/gateway/protocol/schema/exec-approvals.ts create mode 100644 src/gateway/server-methods/exec-approvals.ts create mode 100644 ui/src/ui/controllers/exec-approvals.ts diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift index 7d95ae2a8..bb342a874 100644 --- a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift +++ b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift @@ -13,18 +13,16 @@ struct SystemRunSettingsView: View { Text("Exec approvals") .font(.body) Spacer(minLength: 0) - if self.model.agentIds.count > 1 { - Picker("Agent", selection: Binding( - get: { self.model.selectedAgentId }, - set: { self.model.selectAgent($0) })) - { - ForEach(self.model.agentIds, id: \.self) { id in - Text(id).tag(id) - } + Picker("Agent", selection: Binding( + get: { self.model.selectedAgentId }, + set: { self.model.selectAgent($0) })) + { + ForEach(self.model.agentPickerIds, id: \.self) { id in + Text(self.model.label(for: id)).tag(id) } - .pickerStyle(.menu) - .frame(width: 160, alignment: .trailing) } + .pickerStyle(.menu) + .frame(width: 180, alignment: .trailing) } Picker("", selection: self.$tab) { @@ -82,7 +80,9 @@ struct SystemRunSettingsView: View { .labelsHidden() .pickerStyle(.menu) - Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.") + Text(self.model.isDefaultsScope + ? "Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable." + : "Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.") .font(.footnote) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) @@ -101,31 +101,37 @@ struct SystemRunSettingsView: View { .foregroundStyle(.secondary) } - HStack(spacing: 8) { - TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern) - .textFieldStyle(.roundedBorder) - Button("Add") { - let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines) - guard !pattern.isEmpty else { return } - self.model.addEntry(pattern) - self.newPattern = "" - } - .buttonStyle(.bordered) - .disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - - if self.model.entries.isEmpty { - Text("No allowlisted commands yet.") + if self.model.isDefaultsScope { + Text("Allowlists are per-agent. Select an agent to edit its allowlist.") .font(.footnote) .foregroundStyle(.secondary) } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in - ExecAllowlistRow( - entry: Binding( - get: { self.model.entries[index] }, - set: { self.model.updateEntry($0, at: index) }), - onRemove: { self.model.removeEntry(at: index) }) + HStack(spacing: 8) { + TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern) + .textFieldStyle(.roundedBorder) + Button("Add") { + let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !pattern.isEmpty else { return } + self.model.addEntry(pattern) + self.newPattern = "" + } + .buttonStyle(.bordered) + .disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if self.model.entries.isEmpty { + Text("No allowlisted commands yet.") + .font(.footnote) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in + ExecAllowlistRow( + entry: Binding( + get: { self.model.entries[index] }, + set: { self.model.updateEntry($0, at: index) }), + onRemove: { self.model.removeEntry(at: index) }) + } } } } @@ -177,8 +183,16 @@ struct ExecAllowlistRow: View { Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))") .font(.caption) .foregroundStyle(.secondary) - } else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty { - Text("Last used: \(lastUsedCommand)") + } + + if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty { + Text("Last command: \(lastUsedCommand)") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty { + Text("Resolved path: \(lastResolvedPath)") .font(.caption) .foregroundStyle(.secondary) } @@ -201,6 +215,7 @@ struct ExecAllowlistRow: View { @MainActor @Observable final class ExecApprovalsSettingsModel { + private static let defaultsScopeId = "__defaults__" var agentIds: [String] = [] var selectedAgentId: String = "main" var defaultAgentId: String = "main" @@ -211,6 +226,19 @@ final class ExecApprovalsSettingsModel { var entries: [ExecAllowlistEntry] = [] var skillBins: [String] = [] + var agentPickerIds: [String] { + [Self.defaultsScopeId] + self.agentIds + } + + var isDefaultsScope: Bool { + self.selectedAgentId == Self.defaultsScopeId + } + + func label(for id: String) -> String { + if id == Self.defaultsScopeId { return "Defaults" } + return id + } + func refresh() async { await self.refreshAgents() self.loadSettings(for: self.selectedAgentId) @@ -242,6 +270,9 @@ final class ExecApprovalsSettingsModel { } self.agentIds = ids self.defaultAgentId = defaultId ?? "main" + if self.selectedAgentId == Self.defaultsScopeId { + return + } if !self.agentIds.contains(self.selectedAgentId) { self.selectedAgentId = self.defaultAgentId } @@ -254,6 +285,15 @@ final class ExecApprovalsSettingsModel { } func loadSettings(for agentId: String) { + if agentId == Self.defaultsScopeId { + let defaults = ExecApprovalsStore.resolveDefaults() + self.security = defaults.security + self.ask = defaults.ask + self.askFallback = defaults.askFallback + self.autoAllowSkills = defaults.autoAllowSkills + self.entries = [] + return + } let resolved = ExecApprovalsStore.resolve(agentId: agentId) self.security = resolved.agent.security self.ask = resolved.agent.ask @@ -265,36 +305,61 @@ final class ExecApprovalsSettingsModel { func setSecurity(_ security: ExecSecurity) { self.security = security - ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in - entry.security = security + if self.isDefaultsScope { + ExecApprovalsStore.updateDefaults { defaults in + defaults.security = security + } + } else { + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.security = security + } } self.syncQuickMode() } func setAsk(_ ask: ExecAsk) { self.ask = ask - ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in - entry.ask = ask + if self.isDefaultsScope { + ExecApprovalsStore.updateDefaults { defaults in + defaults.ask = ask + } + } else { + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.ask = ask + } } self.syncQuickMode() } func setAskFallback(_ mode: ExecSecurity) { self.askFallback = mode - ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in - entry.askFallback = mode + if self.isDefaultsScope { + ExecApprovalsStore.updateDefaults { defaults in + defaults.askFallback = mode + } + } else { + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.askFallback = mode + } } } func setAutoAllowSkills(_ enabled: Bool) { self.autoAllowSkills = enabled - ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in - entry.autoAllowSkills = enabled + if self.isDefaultsScope { + ExecApprovalsStore.updateDefaults { defaults in + defaults.autoAllowSkills = enabled + } + } else { + ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in + entry.autoAllowSkills = enabled + } } Task { await self.refreshSkillBins(force: enabled) } } func addEntry(_ pattern: String) { + guard !self.isDefaultsScope else { return } let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil)) @@ -302,12 +367,14 @@ final class ExecApprovalsSettingsModel { } func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) { + guard !self.isDefaultsScope else { return } guard self.entries.indices.contains(index) else { return } self.entries[index] = entry ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } func removeEntry(at index: Int) { + guard !self.isDefaultsScope else { return } guard self.entries.indices.contains(index) else { return } self.entries.remove(at: index) ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) @@ -323,6 +390,10 @@ final class ExecApprovalsSettingsModel { } private func syncQuickMode() { + if self.isDefaultsScope { + AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask) + return + } if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 { AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask) } diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 5ba049299..fdf131d0e 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -100,6 +100,16 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the gateway for the skill bin list. Disable this if you want strict manual allowlists. +## Control UI editing + +Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent +overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy, +add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata +per pattern so you can keep the list tidy. + +Note: the Control UI edits the approvals file on the **Gateway host**. For a +headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly. + ## Approval flow When a prompt is required, the companion app displays a confirmation dialog with: diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index bb7a83869..645b3ee68 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -36,6 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on - Cron jobs: list/add/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) +- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) - Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - Config: apply + restart with validation (`config.apply`) and wake the last active session - Config writes include a base-hash guard to prevent clobbering concurrent edits diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 19faebe55..d90a5461e 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -56,6 +56,11 @@ import { CronStatusParamsSchema, type CronUpdateParams, CronUpdateParamsSchema, + type ExecApprovalsGetParams, + ExecApprovalsGetParamsSchema, + type ExecApprovalsSetParams, + ExecApprovalsSetParamsSchema, + type ExecApprovalsSnapshot, ErrorCodes, type ErrorShape, ErrorShapeSchema, @@ -230,6 +235,12 @@ export const validateCronUpdateParams = ajv.compile(CronUpdate export const validateCronRemoveParams = ajv.compile(CronRemoveParamsSchema); export const validateCronRunParams = ajv.compile(CronRunParamsSchema); export const validateCronRunsParams = ajv.compile(CronRunsParamsSchema); +export const validateExecApprovalsGetParams = ajv.compile( + ExecApprovalsGetParamsSchema, +); +export const validateExecApprovalsSetParams = ajv.compile( + ExecApprovalsSetParamsSchema, +); export const validateLogsTailParams = ajv.compile(LogsTailParamsSchema); export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema); export const validateChatSendParams = ajv.compile(ChatSendParamsSchema); @@ -388,6 +399,9 @@ export type { CronRunParams, CronRunsParams, CronRunLogEntry, + ExecApprovalsGetParams, + ExecApprovalsSetParams, + ExecApprovalsSnapshot, LogsTailParams, LogsTailResult, PollParams, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 65da4a1d6..6880b2928 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -4,6 +4,7 @@ export * from "./schema/channels.js"; export * from "./schema/config.js"; export * from "./schema/cron.js"; export * from "./schema/error-codes.js"; +export * from "./schema/exec-approvals.js"; export * from "./schema/frames.js"; export * from "./schema/logs-chat.js"; export * from "./schema/nodes.js"; diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts new file mode 100644 index 000000000..c9f1f80db --- /dev/null +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -0,0 +1,72 @@ +import { Type } from "@sinclair/typebox"; + +import { NonEmptyString } from "./primitives.js"; + +export const ExecApprovalsAllowlistEntrySchema = Type.Object( + { + pattern: Type.String(), + lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })), + lastUsedCommand: Type.Optional(Type.String()), + lastResolvedPath: Type.Optional(Type.String()), + }, + { additionalProperties: false }, +); + +export const ExecApprovalsDefaultsSchema = Type.Object( + { + security: Type.Optional(Type.String()), + ask: Type.Optional(Type.String()), + askFallback: Type.Optional(Type.String()), + autoAllowSkills: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export const ExecApprovalsAgentSchema = Type.Object( + { + security: Type.Optional(Type.String()), + ask: Type.Optional(Type.String()), + askFallback: Type.Optional(Type.String()), + autoAllowSkills: Type.Optional(Type.Boolean()), + allowlist: Type.Optional(Type.Array(ExecApprovalsAllowlistEntrySchema)), + }, + { additionalProperties: false }, +); + +export const ExecApprovalsFileSchema = Type.Object( + { + version: Type.Literal(1), + socket: Type.Optional( + Type.Object( + { + path: Type.Optional(Type.String()), + token: Type.Optional(Type.String()), + }, + { additionalProperties: false }, + ), + ), + defaults: Type.Optional(ExecApprovalsDefaultsSchema), + agents: Type.Optional(Type.Record(Type.String(), ExecApprovalsAgentSchema)), + }, + { additionalProperties: false }, +); + +export const ExecApprovalsSnapshotSchema = Type.Object( + { + path: NonEmptyString, + exists: Type.Boolean(), + hash: NonEmptyString, + file: ExecApprovalsFileSchema, + }, + { additionalProperties: false }, +); + +export const ExecApprovalsGetParamsSchema = Type.Object({}, { additionalProperties: false }); + +export const ExecApprovalsSetParamsSchema = Type.Object( + { + file: ExecApprovalsFileSchema, + baseHash: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index a6cc04750..4177069bb 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -47,6 +47,11 @@ import { CronStatusParamsSchema, CronUpdateParamsSchema, } from "./cron.js"; +import { + ExecApprovalsGetParamsSchema, + ExecApprovalsSetParamsSchema, + ExecApprovalsSnapshotSchema, +} from "./exec-approvals.js"; import { ConnectParamsSchema, ErrorShapeSchema, @@ -170,6 +175,9 @@ export const ProtocolSchemas: Record = { CronRunLogEntry: CronRunLogEntrySchema, LogsTailParams: LogsTailParamsSchema, LogsTailResult: LogsTailResultSchema, + ExecApprovalsGetParams: ExecApprovalsGetParamsSchema, + ExecApprovalsSetParams: ExecApprovalsSetParamsSchema, + ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema, ChatHistoryParams: ChatHistoryParamsSchema, ChatSendParams: ChatSendParamsSchema, ChatAbortParams: ChatAbortParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index ee6408a41..cc54d34ac 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -45,6 +45,11 @@ import type { CronStatusParamsSchema, CronUpdateParamsSchema, } from "./cron.js"; +import type { + ExecApprovalsGetParamsSchema, + ExecApprovalsSetParamsSchema, + ExecApprovalsSnapshotSchema, +} from "./exec-approvals.js"; import type { ConnectParamsSchema, ErrorShapeSchema, @@ -163,6 +168,9 @@ export type CronRunsParams = Static; export type CronRunLogEntry = Static; export type LogsTailParams = Static; export type LogsTailResult = Static; +export type ExecApprovalsGetParams = Static; +export type ExecApprovalsSetParams = Static; +export type ExecApprovalsSnapshot = Static; export type ChatAbortParams = Static; export type ChatInjectParams = Static; export type ChatEvent = Static; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 59823232b..08ebd6e23 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -12,6 +12,8 @@ const BASE_METHODS = [ "config.apply", "config.patch", "config.schema", + "exec.approvals.get", + "exec.approvals.set", "wizard.start", "wizard.next", "wizard.cancel", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index bd0d7fce5..5e1a069ba 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -6,6 +6,7 @@ import { chatHandlers } from "./server-methods/chat.js"; import { configHandlers } from "./server-methods/config.js"; import { connectHandlers } from "./server-methods/connect.js"; import { cronHandlers } from "./server-methods/cron.js"; +import { execApprovalsHandlers } from "./server-methods/exec-approvals.js"; import { healthHandlers } from "./server-methods/health.js"; import { logsHandlers } from "./server-methods/logs.js"; import { modelsHandlers } from "./server-methods/models.js"; @@ -30,6 +31,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...channelsHandlers, ...chatHandlers, ...cronHandlers, + ...execApprovalsHandlers, ...webHandlers, ...modelsHandlers, ...configHandlers, diff --git a/src/gateway/server-methods/exec-approvals.ts b/src/gateway/server-methods/exec-approvals.ts new file mode 100644 index 000000000..ef061793b --- /dev/null +++ b/src/gateway/server-methods/exec-approvals.ts @@ -0,0 +1,157 @@ +import { + ensureExecApprovals, + normalizeExecApprovals, + readExecApprovalsSnapshot, + resolveExecApprovalsSocketPath, + saveExecApprovals, + type ExecApprovalsFile, + type ExecApprovalsSnapshot, +} from "../../infra/exec-approvals.js"; +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateExecApprovalsGetParams, + validateExecApprovalsSetParams, +} from "../protocol/index.js"; +import type { GatewayRequestHandlers, RespondFn } from "./types.js"; + +function resolveBaseHash(params: unknown): string | null { + const raw = (params as { baseHash?: unknown })?.baseHash; + if (typeof raw !== "string") return null; + const trimmed = raw.trim(); + return trimmed ? trimmed : null; +} + +function requireApprovalsBaseHash( + params: unknown, + snapshot: ExecApprovalsSnapshot, + respond: RespondFn, +): boolean { + if (!snapshot.exists) return true; + if (!snapshot.hash) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "exec approvals base hash unavailable; re-run exec.approvals.get and retry", + ), + ); + return false; + } + const baseHash = resolveBaseHash(params); + if (!baseHash) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "exec approvals base hash required; re-run exec.approvals.get and retry", + ), + ); + return false; + } + if (baseHash !== snapshot.hash) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "exec approvals changed since last load; re-run exec.approvals.get and retry", + ), + ); + return false; + } + return true; +} + +function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { + const socketPath = file.socket?.path?.trim(); + return { + ...file, + socket: socketPath ? { path: socketPath } : undefined, + }; +} + +export const execApprovalsHandlers: GatewayRequestHandlers = { + "exec.approvals.get": ({ params, respond }) => { + if (!validateExecApprovalsGetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid exec.approvals.get params: ${formatValidationErrors(validateExecApprovalsGetParams.errors)}`, + ), + ); + return; + } + ensureExecApprovals(); + const snapshot = readExecApprovalsSnapshot(); + respond( + true, + { + path: snapshot.path, + exists: snapshot.exists, + hash: snapshot.hash, + file: redactExecApprovals(snapshot.file), + }, + undefined, + ); + }, + "exec.approvals.set": ({ params, respond }) => { + if (!validateExecApprovalsSetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid exec.approvals.set params: ${formatValidationErrors(validateExecApprovalsSetParams.errors)}`, + ), + ); + return; + } + ensureExecApprovals(); + const snapshot = readExecApprovalsSnapshot(); + if (!requireApprovalsBaseHash(params, snapshot, respond)) { + return; + } + const incoming = (params as { file?: unknown }).file; + if (!incoming || typeof incoming !== "object") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "exec approvals file is required"), + ); + return; + } + const normalized = normalizeExecApprovals(incoming as ExecApprovalsFile); + const currentSocketPath = snapshot.file.socket?.path?.trim(); + const currentToken = snapshot.file.socket?.token?.trim(); + const socketPath = + normalized.socket?.path?.trim() ?? + currentSocketPath ?? + resolveExecApprovalsSocketPath(); + const token = normalized.socket?.token?.trim() ?? currentToken ?? ""; + const next: ExecApprovalsFile = { + ...normalized, + socket: { + path: socketPath, + token, + }, + }; + saveExecApprovals(next); + const nextSnapshot = readExecApprovalsSnapshot(); + respond( + true, + { + path: nextSnapshot.path, + exists: nextSnapshot.exists, + hash: nextSnapshot.hash, + file: redactExecApprovals(nextSnapshot.file), + }, + undefined, + ); + }, +}; diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index c78e14912..213ce5a7c 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -36,6 +36,14 @@ export type ExecApprovalsFile = { agents?: Record; }; +export type ExecApprovalsSnapshot = { + path: string; + exists: boolean; + raw: string | null; + file: ExecApprovalsFile; + hash: string; +}; + export type ExecApprovalsResolved = { path: string; socketPath: string; @@ -53,6 +61,13 @@ const DEFAULT_AUTO_ALLOW_SKILLS = false; const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock"; const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json"; +function hashExecApprovalsRaw(raw: string | null): string { + return crypto + .createHash("sha256") + .update(raw ?? "") + .digest("hex"); +} + function expandHome(value: string): string { if (!value) return value; if (value === "~") return os.homedir(); @@ -73,7 +88,7 @@ function ensureDir(filePath: string) { fs.mkdirSync(dir, { recursive: true }); } -function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { +export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { const socketPath = file.socket?.path?.trim(); const token = file.socket?.token?.trim(); const normalized: ExecApprovalsFile = { @@ -97,6 +112,38 @@ function generateToken(): string { return crypto.randomBytes(24).toString("base64url"); } +export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot { + const filePath = resolveExecApprovalsPath(); + if (!fs.existsSync(filePath)) { + const file = normalizeExecApprovals({ version: 1, agents: {} }); + return { + path: filePath, + exists: false, + raw: null, + file, + hash: hashExecApprovalsRaw(null), + }; + } + const raw = fs.readFileSync(filePath, "utf8"); + let parsed: ExecApprovalsFile | null = null; + try { + parsed = JSON.parse(raw) as ExecApprovalsFile; + } catch { + parsed = null; + } + const file = + parsed?.version === 1 + ? normalizeExecApprovals(parsed) + : normalizeExecApprovals({ version: 1, agents: {} }); + return { + path: filePath, + exists: true, + raw, + file, + hash: hashExecApprovalsRaw(raw), + }; +} + export function loadExecApprovals(): ExecApprovalsFile { const filePath = resolveExecApprovalsPath(); try { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 7320e3d09..fa4e0e50b 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -61,6 +61,12 @@ import { updateConfigFormValue, removeConfigFormValue, } from "./controllers/config"; +import { + loadExecApprovals, + removeExecApprovalsFormValue, + saveExecApprovals, + updateExecApprovalsFormValue, +} from "./controllers/exec-approvals"; import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; @@ -298,8 +304,15 @@ export function renderApp(state: AppViewState) { configSaving: state.configSaving, configDirty: state.configFormDirty, configFormMode: state.configFormMode, + execApprovalsLoading: state.execApprovalsLoading, + execApprovalsSaving: state.execApprovalsSaving, + execApprovalsDirty: state.execApprovalsDirty, + execApprovalsSnapshot: state.execApprovalsSnapshot, + execApprovalsForm: state.execApprovalsForm, + execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, onRefresh: () => loadNodes(state), onLoadConfig: () => loadConfig(state), + onLoadExecApprovals: () => loadExecApprovals(state), onBindDefault: (nodeId) => { if (nodeId) { updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); @@ -316,6 +329,14 @@ export function renderApp(state: AppViewState) { } }, onSaveBindings: () => saveConfig(state), + onExecApprovalsSelectAgent: (agentId) => { + state.execApprovalsSelectedAgent = agentId; + }, + onExecApprovalsPatch: (path, value) => + updateExecApprovalsFormValue(state, path, value), + onExecApprovalsRemove: (path) => + removeExecApprovalsFormValue(state, path), + onSaveExecApprovals: () => saveExecApprovals(state), }) : nothing} diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index b47895e00..e62495d6a 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -4,6 +4,7 @@ import { loadChannels } from "./controllers/channels"; import { loadDebug } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; import { loadNodes } from "./controllers/nodes"; +import { loadExecApprovals } from "./controllers/exec-approvals"; import { loadPresence } from "./controllers/presence"; import { loadSessions } from "./controllers/sessions"; import { loadSkills } from "./controllers/skills"; @@ -133,6 +134,7 @@ export async function refreshActiveTab(host: SettingsHost) { if (host.tab === "nodes") { await loadNodes(host as unknown as ClawdbotApp); await loadConfig(host as unknown as ClawdbotApp); + await loadExecApprovals(host as unknown as ClawdbotApp); } if (host.tab === "chat") { await refreshChat(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index ddee09aaf..f7e301faa 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -20,6 +20,10 @@ import type { import type { ChatQueueItem, CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import type { SkillMessage } from "./controllers/skills"; +import type { + ExecApprovalsFile, + ExecApprovalsSnapshot, +} from "./controllers/exec-approvals"; export type AppViewState = { settings: UiSettings; @@ -44,6 +48,12 @@ export type AppViewState = { chatQueue: ChatQueueItem[]; nodesLoading: boolean; nodes: Array>; + execApprovalsLoading: boolean; + execApprovalsSaving: boolean; + execApprovalsDirty: boolean; + execApprovalsSnapshot: ExecApprovalsSnapshot | null; + execApprovalsForm: ExecApprovalsFile | null; + execApprovalsSelectedAgent: string | null; configLoading: boolean; configRaw: string; configValid: boolean | null; @@ -160,4 +170,3 @@ export type AppViewState = { handleLogsAutoFollowToggle: (next: boolean) => void; handleCallDebugMethod: (method: string, params: string) => Promise; }; - diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index b2415722c..8a755902b 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -24,6 +24,10 @@ import type { import { type ChatQueueItem, type CronFormState } from "./ui-types"; import type { EventLogEntry } from "./app-events"; import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults"; +import type { + ExecApprovalsFile, + ExecApprovalsSnapshot, +} from "./controllers/exec-approvals"; import { resetToolStream as resetToolStreamInternal, toggleToolOutput as toggleToolOutputInternal, @@ -104,6 +108,12 @@ export class ClawdbotApp extends LitElement { @state() nodesLoading = false; @state() nodes: Array> = []; + @state() execApprovalsLoading = false; + @state() execApprovalsSaving = false; + @state() execApprovalsDirty = false; + @state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; + @state() execApprovalsForm: ExecApprovalsFile | null = null; + @state() execApprovalsSelectedAgent: string | null = null; @state() configLoading = false; @state() configRaw = "{\n}\n"; diff --git a/ui/src/ui/controllers/exec-approvals.ts b/ui/src/ui/controllers/exec-approvals.ts new file mode 100644 index 000000000..0f81fbd0b --- /dev/null +++ b/ui/src/ui/controllers/exec-approvals.ts @@ -0,0 +1,123 @@ +import type { GatewayBrowserClient } from "../gateway"; +import { cloneConfigObject, removePathValue, setPathValue } from "./config/form-utils"; + +export type ExecApprovalsDefaults = { + security?: string; + ask?: string; + askFallback?: string; + autoAllowSkills?: boolean; +}; + +export type ExecApprovalsAllowlistEntry = { + pattern: string; + lastUsedAt?: number; + lastUsedCommand?: string; + lastResolvedPath?: string; +}; + +export type ExecApprovalsAgent = ExecApprovalsDefaults & { + allowlist?: ExecApprovalsAllowlistEntry[]; +}; + +export type ExecApprovalsFile = { + version?: number; + socket?: { path?: string }; + defaults?: ExecApprovalsDefaults; + agents?: Record; +}; + +export type ExecApprovalsSnapshot = { + path: string; + exists: boolean; + hash: string; + file: ExecApprovalsFile; +}; + +export type ExecApprovalsState = { + client: GatewayBrowserClient | null; + connected: boolean; + execApprovalsLoading: boolean; + execApprovalsSaving: boolean; + execApprovalsDirty: boolean; + execApprovalsSnapshot: ExecApprovalsSnapshot | null; + execApprovalsForm: ExecApprovalsFile | null; + execApprovalsSelectedAgent: string | null; + lastError: string | null; +}; + +export async function loadExecApprovals(state: ExecApprovalsState) { + if (!state.client || !state.connected) return; + if (state.execApprovalsLoading) return; + state.execApprovalsLoading = true; + state.lastError = null; + try { + const res = (await state.client.request( + "exec.approvals.get", + {}, + )) as ExecApprovalsSnapshot; + applyExecApprovalsSnapshot(state, res); + } catch (err) { + state.lastError = String(err); + } finally { + state.execApprovalsLoading = false; + } +} + +export function applyExecApprovalsSnapshot( + state: ExecApprovalsState, + snapshot: ExecApprovalsSnapshot, +) { + state.execApprovalsSnapshot = snapshot; + if (!state.execApprovalsDirty) { + state.execApprovalsForm = cloneConfigObject(snapshot.file ?? {}); + } +} + +export async function saveExecApprovals(state: ExecApprovalsState) { + if (!state.client || !state.connected) return; + state.execApprovalsSaving = true; + state.lastError = null; + try { + const baseHash = state.execApprovalsSnapshot?.hash; + if (!baseHash) { + state.lastError = "Exec approvals hash missing; reload and retry."; + return; + } + const file = + state.execApprovalsForm ?? + state.execApprovalsSnapshot?.file ?? + {}; + await state.client.request("exec.approvals.set", { file, baseHash }); + state.execApprovalsDirty = false; + await loadExecApprovals(state); + } catch (err) { + state.lastError = String(err); + } finally { + state.execApprovalsSaving = false; + } +} + +export function updateExecApprovalsFormValue( + state: ExecApprovalsState, + path: Array, + value: unknown, +) { + const base = cloneConfigObject( + state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, + ); + setPathValue(base, path, value); + state.execApprovalsForm = base; + state.execApprovalsDirty = true; +} + +export function removeExecApprovalsFormValue( + state: ExecApprovalsState, + path: Array, +) { + const base = cloneConfigObject( + state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {}, + ); + removePathValue(base, path); + state.execApprovalsForm = base; + state.execApprovalsDirty = true; +} diff --git a/ui/src/ui/views/nodes.ts b/ui/src/ui/views/nodes.ts index 0579b4ffa..7aac00c2d 100644 --- a/ui/src/ui/views/nodes.ts +++ b/ui/src/ui/views/nodes.ts @@ -1,5 +1,12 @@ import { html, nothing } from "lit"; +import { clampText, formatAgo } from "../format"; +import type { + ExecApprovalsAllowlistEntry, + ExecApprovalsFile, + ExecApprovalsSnapshot, +} from "../controllers/exec-approvals"; + export type NodesProps = { loading: boolean; nodes: Array>; @@ -8,16 +15,29 @@ export type NodesProps = { configSaving: boolean; configDirty: boolean; configFormMode: "form" | "raw"; + execApprovalsLoading: boolean; + execApprovalsSaving: boolean; + execApprovalsDirty: boolean; + execApprovalsSnapshot: ExecApprovalsSnapshot | null; + execApprovalsForm: ExecApprovalsFile | null; + execApprovalsSelectedAgent: string | null; onRefresh: () => void; onLoadConfig: () => void; + onLoadExecApprovals: () => void; onBindDefault: (nodeId: string | null) => void; onBindAgent: (agentIndex: number, nodeId: string | null) => void; onSaveBindings: () => void; + onExecApprovalsSelectAgent: (agentId: string) => void; + onExecApprovalsPatch: (path: Array, value: unknown) => void; + onExecApprovalsRemove: (path: Array) => void; + onSaveExecApprovals: () => void; }; export function renderNodes(props: NodesProps) { const bindingState = resolveBindingsState(props); + const approvalsState = resolveExecApprovalsState(props); return html` + ${renderExecApprovals(approvalsState)} ${renderBindings(bindingState)}
@@ -67,6 +87,55 @@ type BindingState = { formMode: "form" | "raw"; }; +type ExecSecurity = "deny" | "allowlist" | "full"; +type ExecAsk = "off" | "on-miss" | "always"; + +type ExecApprovalsResolvedDefaults = { + security: ExecSecurity; + ask: ExecAsk; + askFallback: ExecSecurity; + autoAllowSkills: boolean; +}; + +type ExecApprovalsAgentOption = { + id: string; + name?: string; + isDefault?: boolean; +}; + +type ExecApprovalsState = { + ready: boolean; + disabled: boolean; + dirty: boolean; + loading: boolean; + saving: boolean; + form: ExecApprovalsFile | null; + defaults: ExecApprovalsResolvedDefaults; + selectedScope: string; + selectedAgent: Record | null; + agents: ExecApprovalsAgentOption[]; + allowlist: ExecApprovalsAllowlistEntry[]; + onSelectScope: (agentId: string) => void; + onPatch: (path: Array, value: unknown) => void; + onRemove: (path: Array) => void; + onLoad: () => void; + onSave: () => void; +}; + +const EXEC_APPROVALS_DEFAULT_SCOPE = "__defaults__"; + +const SECURITY_OPTIONS: Array<{ value: ExecSecurity; label: string }> = [ + { value: "deny", label: "Deny" }, + { value: "allowlist", label: "Allowlist" }, + { value: "full", label: "Full" }, +]; + +const ASK_OPTIONS: Array<{ value: ExecAsk; label: string }> = [ + { value: "off", label: "Off" }, + { value: "on-miss", label: "On miss" }, + { value: "always", label: "Always" }, +]; + function resolveBindingsState(props: NodesProps): BindingState { const config = props.configForm; const nodes = resolveExecNodes(props.nodes); @@ -90,6 +159,114 @@ function resolveBindingsState(props: NodesProps): BindingState { }; } +function normalizeSecurity(value?: string): ExecSecurity { + if (value === "allowlist" || value === "full" || value === "deny") return value; + return "deny"; +} + +function normalizeAsk(value?: string): ExecAsk { + if (value === "always" || value === "off" || value === "on-miss") return value; + return "on-miss"; +} + +function resolveExecApprovalsDefaults( + form: ExecApprovalsFile | null, +): ExecApprovalsResolvedDefaults { + const defaults = form?.defaults ?? {}; + return { + security: normalizeSecurity(defaults.security), + ask: normalizeAsk(defaults.ask), + askFallback: normalizeSecurity(defaults.askFallback ?? "deny"), + autoAllowSkills: Boolean(defaults.autoAllowSkills ?? false), + }; +} + +function resolveConfigAgents(config: Record | null): ExecApprovalsAgentOption[] { + const agentsNode = (config?.agents ?? {}) as Record; + const list = Array.isArray(agentsNode.list) ? agentsNode.list : []; + const agents: ExecApprovalsAgentOption[] = []; + list.forEach((entry) => { + if (!entry || typeof entry !== "object") return; + const record = entry as Record; + const id = typeof record.id === "string" ? record.id.trim() : ""; + if (!id) return; + const name = typeof record.name === "string" ? record.name.trim() : undefined; + const isDefault = record.default === true; + agents.push({ id, name: name || undefined, isDefault }); + }); + return agents; +} + +function resolveExecApprovalsAgents( + config: Record | null, + form: ExecApprovalsFile | null, +): ExecApprovalsAgentOption[] { + const configAgents = resolveConfigAgents(config); + const approvalsAgents = Object.keys(form?.agents ?? {}); + const merged = new Map(); + configAgents.forEach((agent) => merged.set(agent.id, agent)); + approvalsAgents.forEach((id) => { + if (merged.has(id)) return; + merged.set(id, { id }); + }); + const agents = Array.from(merged.values()); + if (agents.length === 0) { + agents.push({ id: "main", isDefault: true }); + } + agents.sort((a, b) => { + if (a.isDefault && !b.isDefault) return -1; + if (!a.isDefault && b.isDefault) return 1; + const aLabel = a.name?.trim() ? a.name : a.id; + const bLabel = b.name?.trim() ? b.name : b.id; + return aLabel.localeCompare(bLabel); + }); + return agents; +} + +function resolveExecApprovalsScope( + selected: string | null, + agents: ExecApprovalsAgentOption[], +): string { + if (selected === EXEC_APPROVALS_DEFAULT_SCOPE) return EXEC_APPROVALS_DEFAULT_SCOPE; + if (selected && agents.some((agent) => agent.id === selected)) return selected; + return EXEC_APPROVALS_DEFAULT_SCOPE; +} + +function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState { + const form = props.execApprovalsForm ?? props.execApprovalsSnapshot?.file ?? null; + const ready = Boolean(form); + const defaults = resolveExecApprovalsDefaults(form); + const agents = resolveExecApprovalsAgents(props.configForm, form); + const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents); + const selectedAgent = + selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE + ? ((form?.agents ?? {})[selectedScope] as Record | undefined) ?? + null + : null; + const allowlist = Array.isArray((selectedAgent as { allowlist?: unknown })?.allowlist) + ? ((selectedAgent as { allowlist?: ExecApprovalsAllowlistEntry[] }).allowlist ?? + []) + : []; + return { + ready, + disabled: props.execApprovalsSaving || props.execApprovalsLoading, + dirty: props.execApprovalsDirty, + loading: props.execApprovalsLoading, + saving: props.execApprovalsSaving, + form, + defaults, + selectedScope, + selectedAgent, + agents, + allowlist, + onSelectScope: props.onExecApprovalsSelectAgent, + onPatch: props.onExecApprovalsPatch, + onRemove: props.onExecApprovalsRemove, + onLoad: props.onLoadExecApprovals, + onSave: props.onSaveExecApprovals, + }; +} + function renderBindings(state: BindingState) { const supportsBinding = state.nodes.length > 0; const defaultValue = state.defaultBinding ?? ""; @@ -171,6 +348,342 @@ function renderBindings(state: BindingState) { `; } +function renderExecApprovals(state: ExecApprovalsState) { + const ready = state.ready; + return html` +
+
+
+
Exec approvals
+
+ Allowlist and approval policy for exec host=gateway/node. +
+
+ +
+ + ${!ready + ? html`
+
Load exec approvals to edit allowlists.
+ +
` + : html` + ${renderExecApprovalsTabs(state)} + ${renderExecApprovalsPolicy(state)} + ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE + ? nothing + : renderExecApprovalsAllowlist(state)} + `} +
+ `; +} + +function renderExecApprovalsTabs(state: ExecApprovalsState) { + return html` +
+ Scope +
+ + ${state.agents.map((agent) => { + const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id; + return html` + + `; + })} +
+
+ `; +} + +function renderExecApprovalsPolicy(state: ExecApprovalsState) { + const isDefaults = state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE; + const defaults = state.defaults; + const agent = state.selectedAgent ?? {}; + const basePath = isDefaults ? ["defaults"] : ["agents", state.selectedScope]; + const agentSecurity = typeof agent.security === "string" ? agent.security : undefined; + const agentAsk = typeof agent.ask === "string" ? agent.ask : undefined; + const agentAskFallback = + typeof agent.askFallback === "string" ? agent.askFallback : undefined; + const securityValue = isDefaults ? defaults.security : agentSecurity ?? "__default__"; + const askValue = isDefaults ? defaults.ask : agentAsk ?? "__default__"; + const askFallbackValue = isDefaults + ? defaults.askFallback + : agentAskFallback ?? "__default__"; + const autoOverride = + typeof agent.autoAllowSkills === "boolean" ? agent.autoAllowSkills : undefined; + const autoEffective = autoOverride ?? defaults.autoAllowSkills; + const autoIsDefault = autoOverride == null; + + return html` +
+
+
+
Security
+
+ ${isDefaults + ? "Default security mode." + : `Default: ${defaults.security}.`} +
+
+
+ +
+
+ +
+
+
Ask
+
+ ${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`} +
+
+
+ +
+
+ +
+
+
Ask fallback
+
+ ${isDefaults + ? "Applied when the UI prompt is unavailable." + : `Default: ${defaults.askFallback}.`} +
+
+
+ +
+
+ +
+
+
Auto-allow skill CLIs
+
+ ${isDefaults + ? "Allow skill executables listed by the Gateway." + : autoIsDefault + ? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).` + : `Override (${autoEffective ? "on" : "off"}).`} +
+
+
+ + ${!isDefaults && !autoIsDefault + ? html`` + : nothing} +
+
+
+ `; +} + +function renderExecApprovalsAllowlist(state: ExecApprovalsState) { + const allowlistPath = ["agents", state.selectedScope, "allowlist"]; + const entries = state.allowlist; + return html` +
+
+
Allowlist
+
Case-insensitive glob patterns.
+
+ +
+
+ ${entries.length === 0 + ? html`
No allowlist entries yet.
` + : entries.map((entry, index) => + renderAllowlistEntry(state, entry, index), + )} +
+ `; +} + +function renderAllowlistEntry( + state: ExecApprovalsState, + entry: ExecApprovalsAllowlistEntry, + index: number, +) { + const lastUsed = entry.lastUsedAt ? formatAgo(entry.lastUsedAt) : "never"; + const lastCommand = entry.lastUsedCommand + ? clampText(entry.lastUsedCommand, 120) + : null; + const lastPath = entry.lastResolvedPath + ? clampText(entry.lastResolvedPath, 120) + : null; + return html` +
+
+
${entry.pattern?.trim() ? entry.pattern : "New pattern"}
+
Last used: ${lastUsed}
+ ${lastCommand ? html`
${lastCommand}
` : nothing} + ${lastPath ? html`
${lastPath}
` : nothing} +
+
+ + +
+
+ `; +} + function renderAgentBinding(agent: BindingAgent, state: BindingState) { const bindingValue = agent.binding ?? "__default__"; const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;