import { App } from "../app/app" import { z } from "zod" import { Bus } from "../bus" import { Log } from "../util/log" import { Identifier } from "../id/id" import { Plugin } from "../plugin" export namespace Permission { const log = Log.create({ service: "permission" }) export const Info = z .object({ id: z.string(), type: z.string(), pattern: z.string().optional(), sessionID: z.string(), messageID: z.string(), callID: z.string().optional(), title: z.string(), metadata: z.record(z.any()), time: z.object({ created: z.number(), }), }) .openapi({ ref: "Permission", }) export type Info = z.infer export const Event = { Updated: Bus.event("permission.updated", Info), Replied: Bus.event( "permission.replied", z.object({ sessionID: z.string(), permissionID: z.string(), response: z.string() }), ), } const state = App.state( "permission", () => { const pending: { [sessionID: string]: { [permissionID: string]: { info: Info resolve: () => void reject: (e: any) => void } } } = {} const approved: { [sessionID: string]: { [permissionID: string]: boolean } } = {} return { pending, approved, } }, async (state) => { for (const pending of Object.values(state.pending)) { for (const item of Object.values(pending)) { item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID)) } } }, ) export async function ask(input: { type: Info["type"] title: Info["title"] pattern?: Info["pattern"] callID?: Info["callID"] sessionID: Info["sessionID"] messageID: Info["messageID"] metadata: Info["metadata"] }) { const { pending, approved } = state() log.info("asking", { sessionID: input.sessionID, messageID: input.messageID, toolCallID: input.callID, }) if (approved[input.sessionID]?.[input.pattern ?? input.type]) return const info: Info = { id: Identifier.ascending("permission"), type: input.type, sessionID: input.sessionID, messageID: input.messageID, callID: input.callID, title: input.title, metadata: input.metadata, time: { created: Date.now(), }, } switch ( await Plugin.trigger("permission.ask", info, { status: "ask", }).then((x) => x.status) ) { case "deny": throw new RejectedError(info.sessionID, info.id, info.callID) case "allow": return } pending[input.sessionID] = pending[input.sessionID] || {} return new Promise((resolve, reject) => { pending[input.sessionID][info.id] = { info, resolve, reject, } Bus.publish(Event.Updated, info) }) } export const Response = z.enum(["once", "always", "reject"]) export type Response = z.infer export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) { log.info("response", input) const { pending, approved } = state() const match = pending[input.sessionID]?.[input.permissionID] if (!match) return delete pending[input.sessionID][input.permissionID] if (input.response === "reject") { match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID)) return } match.resolve() Bus.publish(Event.Replied, { sessionID: input.sessionID, permissionID: input.permissionID, response: input.response, }) if (input.response === "always") { approved[input.sessionID] = approved[input.sessionID] || {} approved[input.sessionID][match.info.pattern ?? match.info.type] = true for (const item of Object.values(pending[input.sessionID])) { if ((item.info.pattern ?? item.info.type) === (match.info.pattern ?? match.info.type)) { respond({ sessionID: item.info.sessionID, permissionID: item.info.id, response: input.response }) } } } } export class RejectedError extends Error { constructor( public readonly sessionID: string, public readonly permissionID: string, public readonly toolCallID?: string, ) { super(`The user rejected permission to use this functionality`) } } }