feat: add exec approvals editor in control ui and mac app
This commit is contained in:
@@ -13,18 +13,16 @@ struct SystemRunSettingsView: View {
|
|||||||
Text("Exec approvals")
|
Text("Exec approvals")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
if self.model.agentIds.count > 1 {
|
Picker("Agent", selection: Binding(
|
||||||
Picker("Agent", selection: Binding(
|
get: { self.model.selectedAgentId },
|
||||||
get: { self.model.selectedAgentId },
|
set: { self.model.selectAgent($0) }))
|
||||||
set: { self.model.selectAgent($0) }))
|
{
|
||||||
{
|
ForEach(self.model.agentPickerIds, id: \.self) { id in
|
||||||
ForEach(self.model.agentIds, id: \.self) { id in
|
Text(self.model.label(for: id)).tag(id)
|
||||||
Text(id).tag(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
|
||||||
.frame(width: 160, alignment: .trailing)
|
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(width: 180, alignment: .trailing)
|
||||||
}
|
}
|
||||||
|
|
||||||
Picker("", selection: self.$tab) {
|
Picker("", selection: self.$tab) {
|
||||||
@@ -82,7 +80,9 @@ struct SystemRunSettingsView: View {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.menu)
|
.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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -101,31 +101,37 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
if self.model.isDefaultsScope {
|
||||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
||||||
.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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||||
ExecAllowlistRow(
|
.textFieldStyle(.roundedBorder)
|
||||||
entry: Binding(
|
Button("Add") {
|
||||||
get: { self.model.entries[index] },
|
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
set: { self.model.updateEntry($0, at: index) }),
|
guard !pattern.isEmpty else { return }
|
||||||
onRemove: { self.model.removeEntry(at: index) })
|
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()))")
|
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -201,6 +215,7 @@ struct ExecAllowlistRow: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class ExecApprovalsSettingsModel {
|
final class ExecApprovalsSettingsModel {
|
||||||
|
private static let defaultsScopeId = "__defaults__"
|
||||||
var agentIds: [String] = []
|
var agentIds: [String] = []
|
||||||
var selectedAgentId: String = "main"
|
var selectedAgentId: String = "main"
|
||||||
var defaultAgentId: String = "main"
|
var defaultAgentId: String = "main"
|
||||||
@@ -211,6 +226,19 @@ final class ExecApprovalsSettingsModel {
|
|||||||
var entries: [ExecAllowlistEntry] = []
|
var entries: [ExecAllowlistEntry] = []
|
||||||
var skillBins: [String] = []
|
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 {
|
func refresh() async {
|
||||||
await self.refreshAgents()
|
await self.refreshAgents()
|
||||||
self.loadSettings(for: self.selectedAgentId)
|
self.loadSettings(for: self.selectedAgentId)
|
||||||
@@ -242,6 +270,9 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
self.agentIds = ids
|
self.agentIds = ids
|
||||||
self.defaultAgentId = defaultId ?? "main"
|
self.defaultAgentId = defaultId ?? "main"
|
||||||
|
if self.selectedAgentId == Self.defaultsScopeId {
|
||||||
|
return
|
||||||
|
}
|
||||||
if !self.agentIds.contains(self.selectedAgentId) {
|
if !self.agentIds.contains(self.selectedAgentId) {
|
||||||
self.selectedAgentId = self.defaultAgentId
|
self.selectedAgentId = self.defaultAgentId
|
||||||
}
|
}
|
||||||
@@ -254,6 +285,15 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSettings(for agentId: String) {
|
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)
|
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
||||||
self.security = resolved.agent.security
|
self.security = resolved.agent.security
|
||||||
self.ask = resolved.agent.ask
|
self.ask = resolved.agent.ask
|
||||||
@@ -265,36 +305,61 @@ final class ExecApprovalsSettingsModel {
|
|||||||
|
|
||||||
func setSecurity(_ security: ExecSecurity) {
|
func setSecurity(_ security: ExecSecurity) {
|
||||||
self.security = security
|
self.security = security
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
if self.isDefaultsScope {
|
||||||
entry.security = security
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.security = security
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.security = security
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.syncQuickMode()
|
self.syncQuickMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAsk(_ ask: ExecAsk) {
|
func setAsk(_ ask: ExecAsk) {
|
||||||
self.ask = ask
|
self.ask = ask
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
if self.isDefaultsScope {
|
||||||
entry.ask = ask
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.ask = ask
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.ask = ask
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.syncQuickMode()
|
self.syncQuickMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAskFallback(_ mode: ExecSecurity) {
|
func setAskFallback(_ mode: ExecSecurity) {
|
||||||
self.askFallback = mode
|
self.askFallback = mode
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
if self.isDefaultsScope {
|
||||||
entry.askFallback = mode
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.askFallback = mode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.askFallback = mode
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAutoAllowSkills(_ enabled: Bool) {
|
func setAutoAllowSkills(_ enabled: Bool) {
|
||||||
self.autoAllowSkills = enabled
|
self.autoAllowSkills = enabled
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
if self.isDefaultsScope {
|
||||||
entry.autoAllowSkills = enabled
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.autoAllowSkills = enabled
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.autoAllowSkills = enabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task { await self.refreshSkillBins(force: enabled) }
|
Task { await self.refreshSkillBins(force: enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func addEntry(_ pattern: String) {
|
func addEntry(_ pattern: String) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||||
@@ -302,12 +367,14 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries[index] = entry
|
self.entries[index] = entry
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(at index: Int) {
|
func removeEntry(at index: Int) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries.remove(at: index)
|
self.entries.remove(at: index)
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
@@ -323,6 +390,10 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func syncQuickMode() {
|
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 {
|
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
## Approval flow
|
||||||
|
|
||||||
When a prompt is required, the companion app displays a confirmation dialog with:
|
When a prompt is required, the companion app displays a confirmation dialog with:
|
||||||
|
|||||||
@@ -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.*`)
|
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||||
- Nodes: list + caps (`node.list`)
|
- 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: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
- 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
|
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ import {
|
|||||||
CronStatusParamsSchema,
|
CronStatusParamsSchema,
|
||||||
type CronUpdateParams,
|
type CronUpdateParams,
|
||||||
CronUpdateParamsSchema,
|
CronUpdateParamsSchema,
|
||||||
|
type ExecApprovalsGetParams,
|
||||||
|
ExecApprovalsGetParamsSchema,
|
||||||
|
type ExecApprovalsSetParams,
|
||||||
|
ExecApprovalsSetParamsSchema,
|
||||||
|
type ExecApprovalsSnapshot,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -230,6 +235,12 @@ export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(CronUpdate
|
|||||||
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
|
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
|
||||||
export const validateCronRunParams = ajv.compile<CronRunParams>(CronRunParamsSchema);
|
export const validateCronRunParams = ajv.compile<CronRunParams>(CronRunParamsSchema);
|
||||||
export const validateCronRunsParams = ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
export const validateCronRunsParams = ajv.compile<CronRunsParams>(CronRunsParamsSchema);
|
||||||
|
export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams>(
|
||||||
|
ExecApprovalsGetParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
||||||
|
ExecApprovalsSetParamsSchema,
|
||||||
|
);
|
||||||
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
|
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
|
||||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||||
@@ -388,6 +399,9 @@ export type {
|
|||||||
CronRunParams,
|
CronRunParams,
|
||||||
CronRunsParams,
|
CronRunsParams,
|
||||||
CronRunLogEntry,
|
CronRunLogEntry,
|
||||||
|
ExecApprovalsGetParams,
|
||||||
|
ExecApprovalsSetParams,
|
||||||
|
ExecApprovalsSnapshot,
|
||||||
LogsTailParams,
|
LogsTailParams,
|
||||||
LogsTailResult,
|
LogsTailResult,
|
||||||
PollParams,
|
PollParams,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export * from "./schema/channels.js";
|
|||||||
export * from "./schema/config.js";
|
export * from "./schema/config.js";
|
||||||
export * from "./schema/cron.js";
|
export * from "./schema/cron.js";
|
||||||
export * from "./schema/error-codes.js";
|
export * from "./schema/error-codes.js";
|
||||||
|
export * from "./schema/exec-approvals.js";
|
||||||
export * from "./schema/frames.js";
|
export * from "./schema/frames.js";
|
||||||
export * from "./schema/logs-chat.js";
|
export * from "./schema/logs-chat.js";
|
||||||
export * from "./schema/nodes.js";
|
export * from "./schema/nodes.js";
|
||||||
|
|||||||
72
src/gateway/protocol/schema/exec-approvals.ts
Normal file
72
src/gateway/protocol/schema/exec-approvals.ts
Normal file
@@ -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 },
|
||||||
|
);
|
||||||
@@ -47,6 +47,11 @@ import {
|
|||||||
CronStatusParamsSchema,
|
CronStatusParamsSchema,
|
||||||
CronUpdateParamsSchema,
|
CronUpdateParamsSchema,
|
||||||
} from "./cron.js";
|
} from "./cron.js";
|
||||||
|
import {
|
||||||
|
ExecApprovalsGetParamsSchema,
|
||||||
|
ExecApprovalsSetParamsSchema,
|
||||||
|
ExecApprovalsSnapshotSchema,
|
||||||
|
} from "./exec-approvals.js";
|
||||||
import {
|
import {
|
||||||
ConnectParamsSchema,
|
ConnectParamsSchema,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -170,6 +175,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
CronRunLogEntry: CronRunLogEntrySchema,
|
CronRunLogEntry: CronRunLogEntrySchema,
|
||||||
LogsTailParams: LogsTailParamsSchema,
|
LogsTailParams: LogsTailParamsSchema,
|
||||||
LogsTailResult: LogsTailResultSchema,
|
LogsTailResult: LogsTailResultSchema,
|
||||||
|
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
|
||||||
|
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
|
||||||
|
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
||||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||||
ChatSendParams: ChatSendParamsSchema,
|
ChatSendParams: ChatSendParamsSchema,
|
||||||
ChatAbortParams: ChatAbortParamsSchema,
|
ChatAbortParams: ChatAbortParamsSchema,
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ import type {
|
|||||||
CronStatusParamsSchema,
|
CronStatusParamsSchema,
|
||||||
CronUpdateParamsSchema,
|
CronUpdateParamsSchema,
|
||||||
} from "./cron.js";
|
} from "./cron.js";
|
||||||
|
import type {
|
||||||
|
ExecApprovalsGetParamsSchema,
|
||||||
|
ExecApprovalsSetParamsSchema,
|
||||||
|
ExecApprovalsSnapshotSchema,
|
||||||
|
} from "./exec-approvals.js";
|
||||||
import type {
|
import type {
|
||||||
ConnectParamsSchema,
|
ConnectParamsSchema,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -163,6 +168,9 @@ export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
|
|||||||
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
|
||||||
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
||||||
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
||||||
|
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
|
||||||
|
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>;
|
||||||
|
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
|
||||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||||
export type ChatEvent = Static<typeof ChatEventSchema>;
|
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const BASE_METHODS = [
|
|||||||
"config.apply",
|
"config.apply",
|
||||||
"config.patch",
|
"config.patch",
|
||||||
"config.schema",
|
"config.schema",
|
||||||
|
"exec.approvals.get",
|
||||||
|
"exec.approvals.set",
|
||||||
"wizard.start",
|
"wizard.start",
|
||||||
"wizard.next",
|
"wizard.next",
|
||||||
"wizard.cancel",
|
"wizard.cancel",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { chatHandlers } from "./server-methods/chat.js";
|
|||||||
import { configHandlers } from "./server-methods/config.js";
|
import { configHandlers } from "./server-methods/config.js";
|
||||||
import { connectHandlers } from "./server-methods/connect.js";
|
import { connectHandlers } from "./server-methods/connect.js";
|
||||||
import { cronHandlers } from "./server-methods/cron.js";
|
import { cronHandlers } from "./server-methods/cron.js";
|
||||||
|
import { execApprovalsHandlers } from "./server-methods/exec-approvals.js";
|
||||||
import { healthHandlers } from "./server-methods/health.js";
|
import { healthHandlers } from "./server-methods/health.js";
|
||||||
import { logsHandlers } from "./server-methods/logs.js";
|
import { logsHandlers } from "./server-methods/logs.js";
|
||||||
import { modelsHandlers } from "./server-methods/models.js";
|
import { modelsHandlers } from "./server-methods/models.js";
|
||||||
@@ -30,6 +31,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
|||||||
...channelsHandlers,
|
...channelsHandlers,
|
||||||
...chatHandlers,
|
...chatHandlers,
|
||||||
...cronHandlers,
|
...cronHandlers,
|
||||||
|
...execApprovalsHandlers,
|
||||||
...webHandlers,
|
...webHandlers,
|
||||||
...modelsHandlers,
|
...modelsHandlers,
|
||||||
...configHandlers,
|
...configHandlers,
|
||||||
|
|||||||
157
src/gateway/server-methods/exec-approvals.ts
Normal file
157
src/gateway/server-methods/exec-approvals.ts
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -36,6 +36,14 @@ export type ExecApprovalsFile = {
|
|||||||
agents?: Record<string, ExecApprovalsAgent>;
|
agents?: Record<string, ExecApprovalsAgent>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExecApprovalsSnapshot = {
|
||||||
|
path: string;
|
||||||
|
exists: boolean;
|
||||||
|
raw: string | null;
|
||||||
|
file: ExecApprovalsFile;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ExecApprovalsResolved = {
|
export type ExecApprovalsResolved = {
|
||||||
path: string;
|
path: string;
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
@@ -53,6 +61,13 @@ const DEFAULT_AUTO_ALLOW_SKILLS = false;
|
|||||||
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
|
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
|
||||||
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
|
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 {
|
function expandHome(value: string): string {
|
||||||
if (!value) return value;
|
if (!value) return value;
|
||||||
if (value === "~") return os.homedir();
|
if (value === "~") return os.homedir();
|
||||||
@@ -73,7 +88,7 @@ function ensureDir(filePath: string) {
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||||
const socketPath = file.socket?.path?.trim();
|
const socketPath = file.socket?.path?.trim();
|
||||||
const token = file.socket?.token?.trim();
|
const token = file.socket?.token?.trim();
|
||||||
const normalized: ExecApprovalsFile = {
|
const normalized: ExecApprovalsFile = {
|
||||||
@@ -97,6 +112,38 @@ function generateToken(): string {
|
|||||||
return crypto.randomBytes(24).toString("base64url");
|
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 {
|
export function loadExecApprovals(): ExecApprovalsFile {
|
||||||
const filePath = resolveExecApprovalsPath();
|
const filePath = resolveExecApprovalsPath();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ import {
|
|||||||
updateConfigFormValue,
|
updateConfigFormValue,
|
||||||
removeConfigFormValue,
|
removeConfigFormValue,
|
||||||
} from "./controllers/config";
|
} from "./controllers/config";
|
||||||
|
import {
|
||||||
|
loadExecApprovals,
|
||||||
|
removeExecApprovalsFormValue,
|
||||||
|
saveExecApprovals,
|
||||||
|
updateExecApprovalsFormValue,
|
||||||
|
} from "./controllers/exec-approvals";
|
||||||
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
|
||||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||||
import { loadLogs } from "./controllers/logs";
|
import { loadLogs } from "./controllers/logs";
|
||||||
@@ -298,8 +304,15 @@ export function renderApp(state: AppViewState) {
|
|||||||
configSaving: state.configSaving,
|
configSaving: state.configSaving,
|
||||||
configDirty: state.configFormDirty,
|
configDirty: state.configFormDirty,
|
||||||
configFormMode: state.configFormMode,
|
configFormMode: state.configFormMode,
|
||||||
|
execApprovalsLoading: state.execApprovalsLoading,
|
||||||
|
execApprovalsSaving: state.execApprovalsSaving,
|
||||||
|
execApprovalsDirty: state.execApprovalsDirty,
|
||||||
|
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||||
|
execApprovalsForm: state.execApprovalsForm,
|
||||||
|
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||||
onRefresh: () => loadNodes(state),
|
onRefresh: () => loadNodes(state),
|
||||||
onLoadConfig: () => loadConfig(state),
|
onLoadConfig: () => loadConfig(state),
|
||||||
|
onLoadExecApprovals: () => loadExecApprovals(state),
|
||||||
onBindDefault: (nodeId) => {
|
onBindDefault: (nodeId) => {
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||||
@@ -316,6 +329,14 @@ export function renderApp(state: AppViewState) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveBindings: () => saveConfig(state),
|
onSaveBindings: () => saveConfig(state),
|
||||||
|
onExecApprovalsSelectAgent: (agentId) => {
|
||||||
|
state.execApprovalsSelectedAgent = agentId;
|
||||||
|
},
|
||||||
|
onExecApprovalsPatch: (path, value) =>
|
||||||
|
updateExecApprovalsFormValue(state, path, value),
|
||||||
|
onExecApprovalsRemove: (path) =>
|
||||||
|
removeExecApprovalsFormValue(state, path),
|
||||||
|
onSaveExecApprovals: () => saveExecApprovals(state),
|
||||||
})
|
})
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { loadChannels } from "./controllers/channels";
|
|||||||
import { loadDebug } from "./controllers/debug";
|
import { loadDebug } from "./controllers/debug";
|
||||||
import { loadLogs } from "./controllers/logs";
|
import { loadLogs } from "./controllers/logs";
|
||||||
import { loadNodes } from "./controllers/nodes";
|
import { loadNodes } from "./controllers/nodes";
|
||||||
|
import { loadExecApprovals } from "./controllers/exec-approvals";
|
||||||
import { loadPresence } from "./controllers/presence";
|
import { loadPresence } from "./controllers/presence";
|
||||||
import { loadSessions } from "./controllers/sessions";
|
import { loadSessions } from "./controllers/sessions";
|
||||||
import { loadSkills } from "./controllers/skills";
|
import { loadSkills } from "./controllers/skills";
|
||||||
@@ -133,6 +134,7 @@ export async function refreshActiveTab(host: SettingsHost) {
|
|||||||
if (host.tab === "nodes") {
|
if (host.tab === "nodes") {
|
||||||
await loadNodes(host as unknown as ClawdbotApp);
|
await loadNodes(host as unknown as ClawdbotApp);
|
||||||
await loadConfig(host as unknown as ClawdbotApp);
|
await loadConfig(host as unknown as ClawdbotApp);
|
||||||
|
await loadExecApprovals(host as unknown as ClawdbotApp);
|
||||||
}
|
}
|
||||||
if (host.tab === "chat") {
|
if (host.tab === "chat") {
|
||||||
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
await refreshChat(host as unknown as Parameters<typeof refreshChat>[0]);
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import type {
|
|||||||
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
import type { ChatQueueItem, CronFormState } from "./ui-types";
|
||||||
import type { EventLogEntry } from "./app-events";
|
import type { EventLogEntry } from "./app-events";
|
||||||
import type { SkillMessage } from "./controllers/skills";
|
import type { SkillMessage } from "./controllers/skills";
|
||||||
|
import type {
|
||||||
|
ExecApprovalsFile,
|
||||||
|
ExecApprovalsSnapshot,
|
||||||
|
} from "./controllers/exec-approvals";
|
||||||
|
|
||||||
export type AppViewState = {
|
export type AppViewState = {
|
||||||
settings: UiSettings;
|
settings: UiSettings;
|
||||||
@@ -44,6 +48,12 @@ export type AppViewState = {
|
|||||||
chatQueue: ChatQueueItem[];
|
chatQueue: ChatQueueItem[];
|
||||||
nodesLoading: boolean;
|
nodesLoading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
|
execApprovalsLoading: boolean;
|
||||||
|
execApprovalsSaving: boolean;
|
||||||
|
execApprovalsDirty: boolean;
|
||||||
|
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
||||||
|
execApprovalsForm: ExecApprovalsFile | null;
|
||||||
|
execApprovalsSelectedAgent: string | null;
|
||||||
configLoading: boolean;
|
configLoading: boolean;
|
||||||
configRaw: string;
|
configRaw: string;
|
||||||
configValid: boolean | null;
|
configValid: boolean | null;
|
||||||
@@ -160,4 +170,3 @@ export type AppViewState = {
|
|||||||
handleLogsAutoFollowToggle: (next: boolean) => void;
|
handleLogsAutoFollowToggle: (next: boolean) => void;
|
||||||
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
|
handleCallDebugMethod: (method: string, params: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import type {
|
|||||||
import { type ChatQueueItem, type CronFormState } from "./ui-types";
|
import { type ChatQueueItem, type CronFormState } from "./ui-types";
|
||||||
import type { EventLogEntry } from "./app-events";
|
import type { EventLogEntry } from "./app-events";
|
||||||
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
|
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
|
||||||
|
import type {
|
||||||
|
ExecApprovalsFile,
|
||||||
|
ExecApprovalsSnapshot,
|
||||||
|
} from "./controllers/exec-approvals";
|
||||||
import {
|
import {
|
||||||
resetToolStream as resetToolStreamInternal,
|
resetToolStream as resetToolStreamInternal,
|
||||||
toggleToolOutput as toggleToolOutputInternal,
|
toggleToolOutput as toggleToolOutputInternal,
|
||||||
@@ -104,6 +108,12 @@ export class ClawdbotApp extends LitElement {
|
|||||||
|
|
||||||
@state() nodesLoading = false;
|
@state() nodesLoading = false;
|
||||||
@state() nodes: Array<Record<string, unknown>> = [];
|
@state() nodes: Array<Record<string, unknown>> = [];
|
||||||
|
@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() configLoading = false;
|
||||||
@state() configRaw = "{\n}\n";
|
@state() configRaw = "{\n}\n";
|
||||||
|
|||||||
123
ui/src/ui/controllers/exec-approvals.ts
Normal file
123
ui/src/ui/controllers/exec-approvals.ts
Normal file
@@ -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<string, ExecApprovalsAgent>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string | number>,
|
||||||
|
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<string | number>,
|
||||||
|
) {
|
||||||
|
const base = cloneConfigObject(
|
||||||
|
state.execApprovalsForm ?? state.execApprovalsSnapshot?.file ?? {},
|
||||||
|
);
|
||||||
|
removePathValue(base, path);
|
||||||
|
state.execApprovalsForm = base;
|
||||||
|
state.execApprovalsDirty = true;
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
|
|
||||||
|
import { clampText, formatAgo } from "../format";
|
||||||
|
import type {
|
||||||
|
ExecApprovalsAllowlistEntry,
|
||||||
|
ExecApprovalsFile,
|
||||||
|
ExecApprovalsSnapshot,
|
||||||
|
} from "../controllers/exec-approvals";
|
||||||
|
|
||||||
export type NodesProps = {
|
export type NodesProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
@@ -8,16 +15,29 @@ export type NodesProps = {
|
|||||||
configSaving: boolean;
|
configSaving: boolean;
|
||||||
configDirty: boolean;
|
configDirty: boolean;
|
||||||
configFormMode: "form" | "raw";
|
configFormMode: "form" | "raw";
|
||||||
|
execApprovalsLoading: boolean;
|
||||||
|
execApprovalsSaving: boolean;
|
||||||
|
execApprovalsDirty: boolean;
|
||||||
|
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
||||||
|
execApprovalsForm: ExecApprovalsFile | null;
|
||||||
|
execApprovalsSelectedAgent: string | null;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onLoadConfig: () => void;
|
onLoadConfig: () => void;
|
||||||
|
onLoadExecApprovals: () => void;
|
||||||
onBindDefault: (nodeId: string | null) => void;
|
onBindDefault: (nodeId: string | null) => void;
|
||||||
onBindAgent: (agentIndex: number, nodeId: string | null) => void;
|
onBindAgent: (agentIndex: number, nodeId: string | null) => void;
|
||||||
onSaveBindings: () => void;
|
onSaveBindings: () => void;
|
||||||
|
onExecApprovalsSelectAgent: (agentId: string) => void;
|
||||||
|
onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
|
onExecApprovalsRemove: (path: Array<string | number>) => void;
|
||||||
|
onSaveExecApprovals: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function renderNodes(props: NodesProps) {
|
export function renderNodes(props: NodesProps) {
|
||||||
const bindingState = resolveBindingsState(props);
|
const bindingState = resolveBindingsState(props);
|
||||||
|
const approvalsState = resolveExecApprovalsState(props);
|
||||||
return html`
|
return html`
|
||||||
|
${renderExecApprovals(approvalsState)}
|
||||||
${renderBindings(bindingState)}
|
${renderBindings(bindingState)}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
@@ -67,6 +87,55 @@ type BindingState = {
|
|||||||
formMode: "form" | "raw";
|
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<string, unknown> | null;
|
||||||
|
agents: ExecApprovalsAgentOption[];
|
||||||
|
allowlist: ExecApprovalsAllowlistEntry[];
|
||||||
|
onSelectScope: (agentId: string) => void;
|
||||||
|
onPatch: (path: Array<string | number>, value: unknown) => void;
|
||||||
|
onRemove: (path: Array<string | number>) => 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 {
|
function resolveBindingsState(props: NodesProps): BindingState {
|
||||||
const config = props.configForm;
|
const config = props.configForm;
|
||||||
const nodes = resolveExecNodes(props.nodes);
|
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<string, unknown> | null): ExecApprovalsAgentOption[] {
|
||||||
|
const agentsNode = (config?.agents ?? {}) as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown> | null,
|
||||||
|
form: ExecApprovalsFile | null,
|
||||||
|
): ExecApprovalsAgentOption[] {
|
||||||
|
const configAgents = resolveConfigAgents(config);
|
||||||
|
const approvalsAgents = Object.keys(form?.agents ?? {});
|
||||||
|
const merged = new Map<string, ExecApprovalsAgentOption>();
|
||||||
|
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<string, unknown> | 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) {
|
function renderBindings(state: BindingState) {
|
||||||
const supportsBinding = state.nodes.length > 0;
|
const supportsBinding = state.nodes.length > 0;
|
||||||
const defaultValue = state.defaultBinding ?? "";
|
const defaultValue = state.defaultBinding ?? "";
|
||||||
@@ -171,6 +348,342 @@ function renderBindings(state: BindingState) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderExecApprovals(state: ExecApprovalsState) {
|
||||||
|
const ready = state.ready;
|
||||||
|
return html`
|
||||||
|
<section class="card">
|
||||||
|
<div class="row" style="justify-content: space-between; align-items: center;">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">Exec approvals</div>
|
||||||
|
<div class="card-sub">
|
||||||
|
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
?disabled=${state.disabled || !state.dirty}
|
||||||
|
@click=${state.onSave}
|
||||||
|
>
|
||||||
|
${state.saving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${!ready
|
||||||
|
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
|
||||||
|
<div class="muted">Load exec approvals to edit allowlists.</div>
|
||||||
|
<button class="btn" ?disabled=${state.loading} @click=${state.onLoad}>
|
||||||
|
${state.loading ? "Loading…" : "Load approvals"}
|
||||||
|
</button>
|
||||||
|
</div>`
|
||||||
|
: html`
|
||||||
|
${renderExecApprovalsTabs(state)}
|
||||||
|
${renderExecApprovalsPolicy(state)}
|
||||||
|
${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE
|
||||||
|
? nothing
|
||||||
|
: renderExecApprovalsAllowlist(state)}
|
||||||
|
`}
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExecApprovalsTabs(state: ExecApprovalsState) {
|
||||||
|
return html`
|
||||||
|
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<span class="label">Scope</span>
|
||||||
|
<div class="row" style="gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button
|
||||||
|
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
|
||||||
|
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
|
||||||
|
>
|
||||||
|
Defaults
|
||||||
|
</button>
|
||||||
|
${state.agents.map((agent) => {
|
||||||
|
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
|
||||||
|
return html`
|
||||||
|
<button
|
||||||
|
class="btn btn--sm ${state.selectedScope === agent.id ? "active" : ""}"
|
||||||
|
@click=${() => state.onSelectScope(agent.id)}
|
||||||
|
>
|
||||||
|
${label}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
<div class="list" style="margin-top: 16px;">
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-main">
|
||||||
|
<div class="list-title">Security</div>
|
||||||
|
<div class="list-sub">
|
||||||
|
${isDefaults
|
||||||
|
? "Default security mode."
|
||||||
|
: `Default: ${defaults.security}.`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">
|
||||||
|
<label class="field">
|
||||||
|
<span>Mode</span>
|
||||||
|
<select
|
||||||
|
?disabled=${state.disabled}
|
||||||
|
@change=${(event: Event) => {
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
const value = target.value;
|
||||||
|
if (!isDefaults && value === "__default__") {
|
||||||
|
state.onRemove([...basePath, "security"]);
|
||||||
|
} else {
|
||||||
|
state.onPatch([...basePath, "security"], value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${!isDefaults
|
||||||
|
? html`<option value="__default__" ?selected=${securityValue === "__default__"}>
|
||||||
|
Use default (${defaults.security})
|
||||||
|
</option>`
|
||||||
|
: nothing}
|
||||||
|
${SECURITY_OPTIONS.map(
|
||||||
|
(option) =>
|
||||||
|
html`<option
|
||||||
|
value=${option.value}
|
||||||
|
?selected=${securityValue === option.value}
|
||||||
|
>
|
||||||
|
${option.label}
|
||||||
|
</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-main">
|
||||||
|
<div class="list-title">Ask</div>
|
||||||
|
<div class="list-sub">
|
||||||
|
${isDefaults ? "Default prompt policy." : `Default: ${defaults.ask}.`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">
|
||||||
|
<label class="field">
|
||||||
|
<span>Mode</span>
|
||||||
|
<select
|
||||||
|
?disabled=${state.disabled}
|
||||||
|
@change=${(event: Event) => {
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
const value = target.value;
|
||||||
|
if (!isDefaults && value === "__default__") {
|
||||||
|
state.onRemove([...basePath, "ask"]);
|
||||||
|
} else {
|
||||||
|
state.onPatch([...basePath, "ask"], value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${!isDefaults
|
||||||
|
? html`<option value="__default__" ?selected=${askValue === "__default__"}>
|
||||||
|
Use default (${defaults.ask})
|
||||||
|
</option>`
|
||||||
|
: nothing}
|
||||||
|
${ASK_OPTIONS.map(
|
||||||
|
(option) =>
|
||||||
|
html`<option
|
||||||
|
value=${option.value}
|
||||||
|
?selected=${askValue === option.value}
|
||||||
|
>
|
||||||
|
${option.label}
|
||||||
|
</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-main">
|
||||||
|
<div class="list-title">Ask fallback</div>
|
||||||
|
<div class="list-sub">
|
||||||
|
${isDefaults
|
||||||
|
? "Applied when the UI prompt is unavailable."
|
||||||
|
: `Default: ${defaults.askFallback}.`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">
|
||||||
|
<label class="field">
|
||||||
|
<span>Fallback</span>
|
||||||
|
<select
|
||||||
|
?disabled=${state.disabled}
|
||||||
|
@change=${(event: Event) => {
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
const value = target.value;
|
||||||
|
if (!isDefaults && value === "__default__") {
|
||||||
|
state.onRemove([...basePath, "askFallback"]);
|
||||||
|
} else {
|
||||||
|
state.onPatch([...basePath, "askFallback"], value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${!isDefaults
|
||||||
|
? html`<option value="__default__" ?selected=${askFallbackValue === "__default__"}>
|
||||||
|
Use default (${defaults.askFallback})
|
||||||
|
</option>`
|
||||||
|
: nothing}
|
||||||
|
${SECURITY_OPTIONS.map(
|
||||||
|
(option) =>
|
||||||
|
html`<option
|
||||||
|
value=${option.value}
|
||||||
|
?selected=${askFallbackValue === option.value}
|
||||||
|
>
|
||||||
|
${option.label}
|
||||||
|
</option>`,
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-main">
|
||||||
|
<div class="list-title">Auto-allow skill CLIs</div>
|
||||||
|
<div class="list-sub">
|
||||||
|
${isDefaults
|
||||||
|
? "Allow skill executables listed by the Gateway."
|
||||||
|
: autoIsDefault
|
||||||
|
? `Using default (${defaults.autoAllowSkills ? "on" : "off"}).`
|
||||||
|
: `Override (${autoEffective ? "on" : "off"}).`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">
|
||||||
|
<label class="field">
|
||||||
|
<span>Enabled</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
?disabled=${state.disabled}
|
||||||
|
.checked=${autoEffective}
|
||||||
|
@change=${(event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
state.onPatch([...basePath, "autoAllowSkills"], target.checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
${!isDefaults && !autoIsDefault
|
||||||
|
? html`<button
|
||||||
|
class="btn btn--sm"
|
||||||
|
?disabled=${state.disabled}
|
||||||
|
@click=${() => state.onRemove([...basePath, "autoAllowSkills"])}
|
||||||
|
>
|
||||||
|
Use default
|
||||||
|
</button>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExecApprovalsAllowlist(state: ExecApprovalsState) {
|
||||||
|
const allowlistPath = ["agents", state.selectedScope, "allowlist"];
|
||||||
|
const entries = state.allowlist;
|
||||||
|
return html`
|
||||||
|
<div class="row" style="margin-top: 18px; justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">Allowlist</div>
|
||||||
|
<div class="card-sub">Case-insensitive glob patterns.</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn--sm"
|
||||||
|
?disabled=${state.disabled}
|
||||||
|
@click=${() => {
|
||||||
|
const next = [...entries, { pattern: "" }];
|
||||||
|
state.onPatch(allowlistPath, next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add pattern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="list" style="margin-top: 12px;">
|
||||||
|
${entries.length === 0
|
||||||
|
? html`<div class="muted">No allowlist entries yet.</div>`
|
||||||
|
: entries.map((entry, index) =>
|
||||||
|
renderAllowlistEntry(state, entry, index),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-main">
|
||||||
|
<div class="list-title">${entry.pattern?.trim() ? entry.pattern : "New pattern"}</div>
|
||||||
|
<div class="list-sub">Last used: ${lastUsed}</div>
|
||||||
|
${lastCommand ? html`<div class="list-sub mono">${lastCommand}</div>` : nothing}
|
||||||
|
${lastPath ? html`<div class="list-sub mono">${lastPath}</div>` : nothing}
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">
|
||||||
|
<label class="field">
|
||||||
|
<span>Pattern</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
.value=${entry.pattern ?? ""}
|
||||||
|
?disabled=${state.disabled}
|
||||||
|
@input=${(event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
state.onPatch(
|
||||||
|
["agents", state.selectedScope, "allowlist", index, "pattern"],
|
||||||
|
target.value,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
class="btn btn--sm danger"
|
||||||
|
?disabled=${state.disabled}
|
||||||
|
@click=${() => {
|
||||||
|
if (state.allowlist.length <= 1) {
|
||||||
|
state.onRemove(["agents", state.selectedScope, "allowlist"]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.onRemove(["agents", state.selectedScope, "allowlist", index]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderAgentBinding(agent: BindingAgent, state: BindingState) {
|
function renderAgentBinding(agent: BindingAgent, state: BindingState) {
|
||||||
const bindingValue = agent.binding ?? "__default__";
|
const bindingValue = agent.binding ?? "__default__";
|
||||||
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
|
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;
|
||||||
|
|||||||
Reference in New Issue
Block a user