Skip to content

Commit 5500698

Browse files
committed
wip: tui permissions
1 parent e763176 commit 5500698

26 files changed

Lines changed: 1448 additions & 179 deletions

File tree

packages/opencode/src/permission/index.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { App } from "../app/app"
22
import { z } from "zod"
33
import { Bus } from "../bus"
44
import { Log } from "../util/log"
5+
import { Installation } from "../installation"
56

67
export namespace Permission {
78
const log = Log.create({ service: "permission" })
@@ -10,14 +11,16 @@ export namespace Permission {
1011
.object({
1112
id: z.string(),
1213
sessionID: z.string(),
14+
messageID: z.string(),
15+
toolCallID: z.string().optional(),
1316
title: z.string(),
1417
metadata: z.record(z.any()),
1518
time: z.object({
1619
created: z.number(),
1720
}),
1821
})
1922
.openapi({
20-
ref: "permission.info",
23+
ref: "Permission",
2124
})
2225
export type Info = z.infer<typeof Info>
2326

@@ -52,7 +55,7 @@ export namespace Permission {
5255
async (state) => {
5356
for (const pending of Object.values(state.pending)) {
5457
for (const item of Object.values(pending)) {
55-
item.reject(new RejectedError(item.info.sessionID, item.info.id))
58+
item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.toolCallID))
5659
}
5760
}
5861
},
@@ -61,25 +64,35 @@ export namespace Permission {
6164
export function ask(input: {
6265
id: Info["id"]
6366
sessionID: Info["sessionID"]
67+
messageID: Info["messageID"]
68+
toolCallID?: Info["toolCallID"]
6469
title: Info["title"]
6570
metadata: Info["metadata"]
6671
}) {
67-
return
72+
// TODO: dax, remove this when you're happy with permissions
73+
if (!Installation.isDev()) return
74+
6875
const { pending, approved } = state()
6976
log.info("asking", {
7077
sessionID: input.sessionID,
7178
permissionID: input.id,
79+
messageID: input.messageID,
80+
toolCallID: input.toolCallID,
7281
})
7382
if (approved[input.sessionID]?.[input.id]) {
7483
log.info("previously approved", {
7584
sessionID: input.sessionID,
7685
permissionID: input.id,
86+
messageID: input.messageID,
87+
toolCallID: input.toolCallID,
7788
})
7889
return
7990
}
8091
const info: Info = {
8192
id: input.id,
8293
sessionID: input.sessionID,
94+
messageID: input.messageID,
95+
toolCallID: input.toolCallID,
8396
title: input.title,
8497
metadata: input.metadata,
8598
time: {
@@ -93,29 +106,28 @@ export namespace Permission {
93106
resolve,
94107
reject,
95108
}
96-
setTimeout(() => {
97-
respond({
98-
sessionID: input.sessionID,
99-
permissionID: input.id,
100-
response: "always",
101-
})
102-
}, 1000)
109+
// setTimeout(() => {
110+
// respond({
111+
// sessionID: input.sessionID,
112+
// permissionID: input.id,
113+
// response: "always",
114+
// })
115+
// }, 1000)
103116
Bus.publish(Event.Updated, info)
104117
})
105118
}
106119

107-
export function respond(input: {
108-
sessionID: Info["sessionID"]
109-
permissionID: Info["id"]
110-
response: "once" | "always" | "reject"
111-
}) {
120+
export const Response = z.enum(["once", "always", "reject"])
121+
export type Response = z.infer<typeof Response>
122+
123+
export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) {
112124
log.info("response", input)
113125
const { pending, approved } = state()
114126
const match = pending[input.sessionID]?.[input.permissionID]
115127
if (!match) return
116128
delete pending[input.sessionID][input.permissionID]
117129
if (input.response === "reject") {
118-
match.reject(new RejectedError(input.sessionID, input.permissionID))
130+
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.toolCallID))
119131
return
120132
}
121133
match.resolve()
@@ -129,6 +141,7 @@ export namespace Permission {
129141
constructor(
130142
public readonly sessionID: string,
131143
public readonly permissionID: string,
144+
public readonly toolCallID?: string,
132145
) {
133146
super(`The user rejected permission to use this functionality`)
134147
}

packages/opencode/src/server/server.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { LSP } from "../lsp"
1818
import { MessageV2 } from "../session/message-v2"
1919
import { Mode } from "../session/mode"
2020
import { callTui, TuiRoute } from "./tui"
21+
import { Permission } from "../permission"
2122

2223
const ERRORS = {
2324
400: {
@@ -457,6 +458,39 @@ export namespace Server {
457458
return c.json(messages)
458459
},
459460
)
461+
.get(
462+
"/session/:id/message/:messageID",
463+
describeRoute({
464+
description: "Get a message from a session",
465+
responses: {
466+
200: {
467+
description: "Message",
468+
content: {
469+
"application/json": {
470+
schema: resolver(
471+
z.object({
472+
info: MessageV2.Info,
473+
parts: MessageV2.Part.array(),
474+
}),
475+
),
476+
},
477+
},
478+
},
479+
},
480+
}),
481+
zValidator(
482+
"param",
483+
z.object({
484+
id: z.string().openapi({ description: "Session ID" }),
485+
messageID: z.string().openapi({ description: "Message ID" }),
486+
}),
487+
),
488+
async (c) => {
489+
const params = c.req.valid("param")
490+
const message = await Session.getMessage(params.id, params.messageID)
491+
return c.json(message)
492+
},
493+
)
460494
.post(
461495
"/session/:id/message",
462496
describeRoute({
@@ -545,6 +579,37 @@ export namespace Server {
545579
return c.json(session)
546580
},
547581
)
582+
.post(
583+
"/session/:id/permissions/:permissionID",
584+
describeRoute({
585+
description: "Respond to a permission request",
586+
responses: {
587+
200: {
588+
description: "Permission processed successfully",
589+
content: {
590+
"application/json": {
591+
schema: resolver(z.boolean()),
592+
},
593+
},
594+
},
595+
},
596+
}),
597+
zValidator(
598+
"param",
599+
z.object({
600+
id: z.string(),
601+
permissionID: z.string(),
602+
}),
603+
),
604+
zValidator("json", z.object({ response: Permission.Response })),
605+
async (c) => {
606+
const params = c.req.valid("param")
607+
const id = params.id
608+
const permissionID = params.permissionID
609+
Permission.respond({ sessionID: id, permissionID, response: c.req.valid("json").response })
610+
return c.json(true)
611+
},
612+
)
548613
.get(
549614
"/config/providers",
550615
describeRoute({

packages/opencode/src/session/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,10 @@ export namespace Session {
256256
}
257257

258258
export async function getMessage(sessionID: string, messageID: string) {
259-
return Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID)
259+
return {
260+
info: await Storage.readJSON<MessageV2.Info>("session/message/" + sessionID + "/" + messageID),
261+
parts: await getParts(sessionID, messageID),
262+
}
260263
}
261264

262265
export async function getParts(sessionID: string, messageID: string) {
@@ -714,6 +717,7 @@ export namespace Session {
714717
sessionID: input.sessionID,
715718
abort: abort.signal,
716719
messageID: assistantMsg.id,
720+
toolCallID: options.toolCallId,
717721
metadata: async (val) => {
718722
const match = processor.partFromToolCall(options.toolCallId)
719723
if (match && match.state.status === "running") {

packages/opencode/src/tool/bash.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { z } from "zod"
22
import { Tool } from "./tool"
33
import DESCRIPTION from "./bash.txt"
44
import { App } from "../app/app"
5+
import { Permission } from "../permission"
6+
import { Config } from "../config/config"
57

68
// import Parser from "tree-sitter"
79
// import Bash from "tree-sitter-bash"
@@ -93,6 +95,8 @@ export const BashTool = Tool.define("bash", {
9395
await Permission.ask({
9496
id: "bash",
9597
sessionID: ctx.sessionID,
98+
messageID: ctx.messageID,
99+
toolCallID: ctx.toolCallID,
96100
title: params.command,
97101
metadata: {
98102
command: params.command,
@@ -101,6 +105,21 @@ export const BashTool = Tool.define("bash", {
101105
}
102106
*/
103107

108+
const cfg = await Config.get()
109+
if (cfg.permission?.bash === "ask")
110+
await Permission.ask({
111+
id: "bash",
112+
sessionID: ctx.sessionID,
113+
messageID: ctx.messageID,
114+
toolCallID: ctx.toolCallID,
115+
title: "Run this command: " + params.command,
116+
metadata: {
117+
command: params.command,
118+
description: params.description,
119+
timeout: params.timeout,
120+
},
121+
})
122+
104123
const process = Bun.spawn({
105124
cmd: ["bash", "-c", params.command],
106125
cwd: app.path.cwd,

packages/opencode/src/tool/edit.ts

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -35,61 +35,77 @@ export const EditTool = Tool.define("edit", {
3535
}
3636

3737
const app = App.info()
38-
const filepath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
39-
if (!Filesystem.contains(app.path.cwd, filepath)) {
40-
throw new Error(`File ${filepath} is not in the current working directory`)
38+
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(app.path.cwd, params.filePath)
39+
if (!Filesystem.contains(app.path.cwd, filePath)) {
40+
throw new Error(`File ${filePath} is not in the current working directory`)
4141
}
4242

4343
const cfg = await Config.get()
44-
if (cfg.permission?.edit === "ask")
45-
await Permission.ask({
46-
id: "edit",
47-
sessionID: ctx.sessionID,
48-
title: "Edit this file: " + filepath,
49-
metadata: {
50-
filePath: filepath,
51-
oldString: params.oldString,
52-
newString: params.newString,
53-
},
54-
})
55-
44+
let diff = ""
5645
let contentOld = ""
5746
let contentNew = ""
5847
await (async () => {
5948
if (params.oldString === "") {
6049
contentNew = params.newString
61-
await Bun.write(filepath, params.newString)
50+
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
51+
if (cfg.permission?.edit === "ask") {
52+
await Permission.ask({
53+
id: "edit",
54+
sessionID: ctx.sessionID,
55+
messageID: ctx.messageID,
56+
toolCallID: ctx.toolCallID,
57+
title: "Edit this file: " + filePath,
58+
metadata: {
59+
filePath,
60+
diff,
61+
},
62+
})
63+
}
64+
await Bun.write(filePath, params.newString)
6265
await Bus.publish(File.Event.Edited, {
63-
file: filepath,
66+
file: filePath,
6467
})
6568
return
6669
}
6770

68-
const file = Bun.file(filepath)
71+
const file = Bun.file(filePath)
6972
const stats = await file.stat().catch(() => {})
70-
if (!stats) throw new Error(`File ${filepath} not found`)
71-
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filepath}`)
72-
await FileTime.assert(ctx.sessionID, filepath)
73+
if (!stats) throw new Error(`File ${filePath} not found`)
74+
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
75+
await FileTime.assert(ctx.sessionID, filePath)
7376
contentOld = await file.text()
74-
7577
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
78+
79+
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
80+
if (cfg.permission?.edit === "ask") {
81+
await Permission.ask({
82+
id: "edit",
83+
sessionID: ctx.sessionID,
84+
messageID: ctx.messageID,
85+
toolCallID: ctx.toolCallID,
86+
title: "Edit this file: " + filePath,
87+
metadata: {
88+
filePath,
89+
diff,
90+
},
91+
})
92+
}
93+
7694
await file.write(contentNew)
7795
await Bus.publish(File.Event.Edited, {
78-
file: filepath,
96+
file: filePath,
7997
})
8098
contentNew = await file.text()
8199
})()
82100

83-
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
84-
85-
FileTime.read(ctx.sessionID, filepath)
101+
FileTime.read(ctx.sessionID, filePath)
86102

87103
let output = ""
88-
await LSP.touchFile(filepath, true)
104+
await LSP.touchFile(filePath, true)
89105
const diagnostics = await LSP.diagnostics()
90106
for (const [file, issues] of Object.entries(diagnostics)) {
91107
if (issues.length === 0) continue
92-
if (file === filepath) {
108+
if (file === filePath) {
93109
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`
94110
continue
95111
}
@@ -104,7 +120,7 @@ export const EditTool = Tool.define("edit", {
104120
diagnostics,
105121
diff,
106122
},
107-
title: `${path.relative(app.path.root, filepath)}`,
123+
title: `${path.relative(app.path.root, filePath)}`,
108124
output,
109125
}
110126
},

0 commit comments

Comments
 (0)