wip: tui permissions

This commit is contained in:
adamdotdevin
2025-07-31 09:34:43 -05:00
parent e7631763f3
commit 5500698734
26 changed files with 1448 additions and 179 deletions

View File

@@ -2,6 +2,7 @@ import { App } from "../app/app"
import { z } from "zod"
import { Bus } from "../bus"
import { Log } from "../util/log"
import { Installation } from "../installation"
export namespace Permission {
const log = Log.create({ service: "permission" })
@@ -10,6 +11,8 @@ export namespace Permission {
.object({
id: z.string(),
sessionID: z.string(),
messageID: z.string(),
toolCallID: z.string().optional(),
title: z.string(),
metadata: z.record(z.any()),
time: z.object({
@@ -17,7 +20,7 @@ export namespace Permission {
}),
})
.openapi({
ref: "permission.info",
ref: "Permission",
})
export type Info = z.infer<typeof Info>
@@ -52,7 +55,7 @@ export namespace Permission {
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.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.toolCallID))
}
}
},
@@ -61,25 +64,35 @@ export namespace Permission {
export function ask(input: {
id: Info["id"]
sessionID: Info["sessionID"]
messageID: Info["messageID"]
toolCallID?: Info["toolCallID"]
title: Info["title"]
metadata: Info["metadata"]
}) {
return
// TODO: dax, remove this when you're happy with permissions
if (!Installation.isDev()) return
const { pending, approved } = state()
log.info("asking", {
sessionID: input.sessionID,
permissionID: input.id,
messageID: input.messageID,
toolCallID: input.toolCallID,
})
if (approved[input.sessionID]?.[input.id]) {
log.info("previously approved", {
sessionID: input.sessionID,
permissionID: input.id,
messageID: input.messageID,
toolCallID: input.toolCallID,
})
return
}
const info: Info = {
id: input.id,
sessionID: input.sessionID,
messageID: input.messageID,
toolCallID: input.toolCallID,
title: input.title,
metadata: input.metadata,
time: {
@@ -93,29 +106,28 @@ export namespace Permission {
resolve,
reject,
}
setTimeout(() => {
respond({
sessionID: input.sessionID,
permissionID: input.id,
response: "always",
})
}, 1000)
// setTimeout(() => {
// respond({
// sessionID: input.sessionID,
// permissionID: input.id,
// response: "always",
// })
// }, 1000)
Bus.publish(Event.Updated, info)
})
}
export function respond(input: {
sessionID: Info["sessionID"]
permissionID: Info["id"]
response: "once" | "always" | "reject"
}) {
export const Response = z.enum(["once", "always", "reject"])
export type Response = z.infer<typeof Response>
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.reject(new RejectedError(input.sessionID, input.permissionID, match.info.toolCallID))
return
}
match.resolve()
@@ -129,6 +141,7 @@ export namespace Permission {
constructor(
public readonly sessionID: string,
public readonly permissionID: string,
public readonly toolCallID?: string,
) {
super(`The user rejected permission to use this functionality`)
}

View File

@@ -18,6 +18,7 @@ import { LSP } from "../lsp"
import { MessageV2 } from "../session/message-v2"
import { Mode } from "../session/mode"
import { callTui, TuiRoute } from "./tui"
import { Permission } from "../permission"
const ERRORS = {
400: {
@@ -457,6 +458,39 @@ export namespace Server {
return c.json(messages)
},
)
.get(
"/session/:id/message/:messageID",
describeRoute({
description: "Get a message from a session",
responses: {
200: {
description: "Message",
content: {
"application/json": {
schema: resolver(
z.object({
info: MessageV2.Info,
parts: MessageV2.Part.array(),
}),
),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string().openapi({ description: "Session ID" }),
messageID: z.string().openapi({ description: "Message ID" }),
}),
),
async (c) => {
const params = c.req.valid("param")
const message = await Session.getMessage(params.id, params.messageID)
return c.json(message)
},
)
.post(
"/session/:id/message",
describeRoute({
@@ -545,6 +579,37 @@ export namespace Server {
return c.json(session)
},
)
.post(
"/session/:id/permissions/:permissionID",
describeRoute({
description: "Respond to a permission request",
responses: {
200: {
description: "Permission processed successfully",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"param",
z.object({
id: z.string(),
permissionID: z.string(),
}),
),
zValidator("json", z.object({ response: Permission.Response })),
async (c) => {
const params = c.req.valid("param")
const id = params.id
const permissionID = params.permissionID
Permission.respond({ sessionID: id, permissionID, response: c.req.valid("json").response })
return c.json(true)
},
)
.get(
"/config/providers",
describeRoute({

View File

@@ -256,7 +256,10 @@ export namespace Session {
}
export async function getMessage(sessionID: string, messageID: string) {
return Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID)
return {
info: await Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID),
parts: await getParts(sessionID, messageID),
}
}
export async function getParts(sessionID: string, messageID: string) {
@@ -714,6 +717,7 @@ export namespace Session {
sessionID: input.sessionID,
abort: abort.signal,
messageID: assistantMsg.id,
toolCallID: options.toolCallId,
metadata: async (val) => {
const match = processor.partFromToolCall(options.toolCallId)
if (match && match.state.status === "running") {

View File

@@ -2,6 +2,8 @@ import { z } from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./bash.txt"
import { App } from "../app/app"
import { Permission } from "../permission"
import { Config } from "../config/config"
// import Parser from "tree-sitter"
// import Bash from "tree-sitter-bash"
@@ -93,6 +95,8 @@ export const BashTool = Tool.define("bash", {
await Permission.ask({
id: "bash",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
toolCallID: ctx.toolCallID,
title: params.command,
metadata: {
command: params.command,
@@ -101,6 +105,21 @@ export const BashTool = Tool.define("bash", {
}
*/
const cfg = await Config.get()
if (cfg.permission?.bash === "ask")
await Permission.ask({
id: "bash",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
toolCallID: ctx.toolCallID,
title: "Run this command: " + params.command,
metadata: {
command: params.command,
description: params.description,
timeout: params.timeout,
},
})
const process = Bun.spawn({
cmd: ["bash", "-c", params.command],
cwd: app.path.cwd,

View File

@@ -35,61 +35,77 @@ export const EditTool = Tool.define("edit", {
}
const app = App.info()
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
if (!Filesystem.contains(app.path.cwd, filepath)) {
throw new Error(`File ${filepath} is not in the current working directory`)
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
if (!Filesystem.contains(app.path.cwd, filePath)) {
throw new Error(`File ${filePath} is not in the current working directory`)
}
const cfg = await Config.get()
if (cfg.permission?.edit === "ask")
await Permission.ask({
id: "edit",
sessionID: ctx.sessionID,
title: "Edit this file: " + filepath,
metadata: {
filePath: filepath,
oldString: params.oldString,
newString: params.newString,
},
})
let diff = ""
let contentOld = ""
let contentNew = ""
await (async () => {
if (params.oldString === "") {
contentNew = params.newString
await Bun.write(filepath, params.newString)
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
if (cfg.permission?.edit === "ask") {
await Permission.ask({
id: "edit",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
toolCallID: ctx.toolCallID,
title: "Edit this file: " + filePath,
metadata: {
filePath,
diff,
},
})
}
await Bun.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filepath,
file: filePath,
})
return
}
const file = Bun.file(filepath)
const file = Bun.file(filePath)
const stats = await file.stat().catch(() => {})
if (!stats) throw new Error(`File ${filepath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
await FileTime.assert(ctx.sessionID, filepath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await file.text()
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
if (cfg.permission?.edit === "ask") {
await Permission.ask({
id: "edit",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
toolCallID: ctx.toolCallID,
title: "Edit this file: " + filePath,
metadata: {
filePath,
diff,
},
})
}
await file.write(contentNew)
await Bus.publish(File.Event.Edited, {
file: filepath,
file: filePath,
})
contentNew = await file.text()
})()
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
FileTime.read(ctx.sessionID, filepath)
FileTime.read(ctx.sessionID, filePath)
let output = ""
await LSP.touchFile(filepath, true)
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
for (const [file, issues] of Object.entries(diagnostics)) {
if (issues.length === 0) continue
if (file === filepath) {
if (file === filePath) {
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
continue
}
@@ -104,7 +120,7 @@ export const EditTool = Tool.define("edit", {
diagnostics,
diff,
},
title: `${path.relative(app.path.root, filepath)}`,
title: `${path.relative(app.path.root, filePath)}`,
output,
}
},

View File

@@ -20,7 +20,7 @@ export const TaskTool = Tool.define("task", async () => {
async execute(params, ctx) {
const session = await Session.create(ctx.sessionID)
const msg = await Session.getMessage(ctx.sessionID, ctx.messageID)
if (msg.role !== "assistant") throw new Error("Not an assistant message")
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
const agent = await Agent.get(params.subagent_type)
const messageID = Identifier.ascending("message")
const parts: Record<string, MessageV2.ToolPart> = {}
@@ -38,8 +38,8 @@ export const TaskTool = Tool.define("task", async () => {
})
const model = agent.model ?? {
modelID: msg.modelID,
providerID: msg.providerID,
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
ctx.abort.addEventListener("abort", () => {
@@ -50,7 +50,7 @@ export const TaskTool = Tool.define("task", async () => {
sessionID: session.id,
modelID: model.modelID,
providerID: model.providerID,
mode: msg.mode,
mode: msg.info.mode,
system: agent.prompt,
tools: {
...agent.tools,

View File

@@ -7,6 +7,7 @@ export namespace Tool {
export type Context<M extends Metadata = Metadata> = {
sessionID: string
messageID: string
toolCallID: string
abort: AbortSignal
metadata(input: { title?: string; metadata?: M }): void
}

View File

@@ -33,6 +33,8 @@ export const WriteTool = Tool.define("write", {
await Permission.ask({
id: "write",
sessionID: ctx.sessionID,
messageID: ctx.messageID,
toolCallID: ctx.toolCallID,
title: exists ? "Overwrite this file: " + filepath : "Create new file: " + filepath,
metadata: {
filePath: filepath,