Improve approval and sandbox flows
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"mime"
|
||||
"os"
|
||||
@@ -219,7 +220,7 @@ func botCommands() []BotCommand {
|
||||
{Command: "cancel", Description: "Interrupt the active turn"},
|
||||
{Command: "workspace", Description: "Select workspace"},
|
||||
{Command: "model", Description: "Choose model"},
|
||||
{Command: "sandbox", Description: "Show or set sandbox"},
|
||||
{Command: "sandbox", Description: "Choose sandbox"},
|
||||
{Command: "pic", Description: "Generate images"},
|
||||
{Command: "diff", Description: "Show latest diff"},
|
||||
{Command: "help", Description: "Show help"},
|
||||
@@ -332,7 +333,7 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error {
|
||||
"/workspaces - list workspaces",
|
||||
"/workspace [ID] - select workspace",
|
||||
"/model - choose model and reasoning effort",
|
||||
"/sandbox [read-only|workspace-write|danger-full-access] - show or set sandbox",
|
||||
"/sandbox - choose sandbox",
|
||||
"/pic PROMPT - generate image(s) from a prompt",
|
||||
"/diff - show the latest streamed diff",
|
||||
"",
|
||||
@@ -776,19 +777,43 @@ func (b *Bot) handleEffortCallback(ctx context.Context, callback *CallbackQuery,
|
||||
}
|
||||
|
||||
func (b *Bot) handleSandboxCommand(ctx context.Context, userID, chatID int64, session store.Session, args []string) error {
|
||||
if len(args) == 0 {
|
||||
_, err := b.tg.SendMessage(ctx, chatID, "Current sandbox: "+session.Sandbox, SendMessageOptions{})
|
||||
_ = userID
|
||||
if len(args) > 0 {
|
||||
_, err := b.tg.SendMessage(ctx, chatID, "Use /sandbox and choose from the buttons.", SendMessageOptions{})
|
||||
return err
|
||||
}
|
||||
sandbox, err := codexapp.NormalizeSandbox(args[0])
|
||||
return b.sendSandboxChoices(ctx, chatID, session)
|
||||
}
|
||||
|
||||
func (b *Bot) sendSandboxChoices(ctx context.Context, chatID int64, session store.Session) error {
|
||||
text := sandboxStatusText(session.Sandbox) + "\nChoose sandbox:"
|
||||
message, err := b.tg.SendMessage(ctx, chatID, EscapeHTML(text), SendMessageOptions{
|
||||
ParseMode: "HTML",
|
||||
ReplyMarkup: sandboxMarkup(session.Sandbox),
|
||||
})
|
||||
if err != nil {
|
||||
_, sendErr := b.tg.SendMessage(ctx, chatID, "Use one of: read-only, workspace-write, danger-full-access.", SendMessageOptions{})
|
||||
return sendErr
|
||||
}
|
||||
if err := b.store.SetSessionSandbox(ctx, userID, sandbox); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = b.tg.SendMessage(ctx, chatID, "Sandbox set to "+sandbox+".", SendMessageOptions{})
|
||||
b.rememberSettingsMessage(ctx, session.TelegramUserID, chatID, message.MessageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) handleSandboxCallback(ctx context.Context, callback *CallbackQuery, sandbox string) error {
|
||||
normalized, err := codexapp.NormalizeSandbox(sandbox)
|
||||
if err != nil {
|
||||
return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Unsupported sandbox.")
|
||||
}
|
||||
if err := b.store.SetSessionSandbox(ctx, callback.From.ID, normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Sandbox selected."); err != nil {
|
||||
return err
|
||||
}
|
||||
text := sandboxStatusText(normalized)
|
||||
message, err := b.tg.EditMessageText(ctx, callback.Message.Chat.ID, callback.Message.MessageID, EscapeHTML(text), EditMessageTextOptions{ParseMode: "HTML"})
|
||||
if err == nil {
|
||||
b.rememberSettingsMessage(ctx, callback.From.ID, callback.Message.Chat.ID, message.MessageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1186,6 +1211,9 @@ func (b *Bot) handleCallback(ctx context.Context, callback *CallbackQuery) error
|
||||
if effort, ok := ParseEffortCallbackData(callback.Data); ok {
|
||||
return b.handleEffortCallback(ctx, callback, effort)
|
||||
}
|
||||
if sandbox, ok := ParseSandboxCallbackData(callback.Data); ok {
|
||||
return b.handleSandboxCallback(ctx, callback, sandbox)
|
||||
}
|
||||
if approvalID, decision, ok := ParseApprovalCallbackData(callback.Data); ok {
|
||||
return b.handleApprovalCallback(ctx, callback, approvalID, decision)
|
||||
}
|
||||
@@ -2202,10 +2230,76 @@ func combineToolApprovalHTML(toolHTML, approvalHTML string) string {
|
||||
case approvalHTML == "":
|
||||
return toolHTML
|
||||
default:
|
||||
return toolHTML + "\n\n" + approvalHTML
|
||||
return toolHTML + "\n\n" + compactApprovalHTMLForTool(toolHTML, approvalHTML)
|
||||
}
|
||||
}
|
||||
|
||||
func compactApprovalHTMLForTool(toolHTML, approvalHTML string) string {
|
||||
return removeDuplicateLabeledQuotes(approvalHTML, labelsPresentInToolHTML(toolHTML))
|
||||
}
|
||||
|
||||
func labelsPresentInToolHTML(toolHTML string) map[string]bool {
|
||||
labels := make(map[string]bool)
|
||||
for _, label := range []string{"CWD", "Command"} {
|
||||
if strings.Contains(toolHTML, "<b>"+EscapeHTML(label)+":</b>") {
|
||||
labels[label] = true
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func removeDuplicateLabeledQuotes(htmlText string, labels map[string]bool) string {
|
||||
if len(labels) == 0 || strings.TrimSpace(htmlText) == "" {
|
||||
return htmlText
|
||||
}
|
||||
const open = "<blockquote expandable>"
|
||||
const close = "</blockquote>"
|
||||
var out strings.Builder
|
||||
searchFrom := 0
|
||||
for {
|
||||
start := strings.Index(htmlText[searchFrom:], open)
|
||||
if start < 0 {
|
||||
out.WriteString(htmlText[searchFrom:])
|
||||
break
|
||||
}
|
||||
start += searchFrom
|
||||
contentStart := start + len(open)
|
||||
end := strings.Index(htmlText[contentStart:], close)
|
||||
if end < 0 {
|
||||
out.WriteString(htmlText[searchFrom:])
|
||||
break
|
||||
}
|
||||
end += contentStart
|
||||
content := htmlText[contentStart:end]
|
||||
label := quoteLabel(content)
|
||||
if labels[label] {
|
||||
out.WriteString(strings.TrimRight(htmlText[searchFrom:start], "\n"))
|
||||
searchFrom = end + len(close)
|
||||
for strings.HasPrefix(htmlText[searchFrom:], "\n") {
|
||||
searchFrom++
|
||||
}
|
||||
continue
|
||||
}
|
||||
out.WriteString(htmlText[searchFrom : end+len(close)])
|
||||
searchFrom = end + len(close)
|
||||
}
|
||||
return strings.TrimSpace(out.String())
|
||||
}
|
||||
|
||||
func quoteLabel(content string) string {
|
||||
const boldOpen = "<b>"
|
||||
const labelClose = ":</b>"
|
||||
content = strings.TrimSpace(content)
|
||||
if !strings.HasPrefix(content, boldOpen) {
|
||||
return ""
|
||||
}
|
||||
end := strings.Index(content, labelClose)
|
||||
if end < 0 {
|
||||
return ""
|
||||
}
|
||||
return html.UnescapeString(content[len(boldOpen):end])
|
||||
}
|
||||
|
||||
func addEditedAtLine(htmlText, editedAt string) string {
|
||||
htmlText = strings.TrimSpace(htmlText)
|
||||
if htmlText == "" || editedAt == "" {
|
||||
@@ -2907,6 +3001,36 @@ func effortMarkup(model codexapp.Model) *InlineKeyboardMarkup {
|
||||
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
||||
}
|
||||
|
||||
func sandboxMarkup(current string) *InlineKeyboardMarkup {
|
||||
options := []struct {
|
||||
Value string
|
||||
Label string
|
||||
}{
|
||||
{Value: "read-only", Label: "Read only"},
|
||||
{Value: "workspace-write", Label: "Workspace write"},
|
||||
{Value: "danger-full-access", Label: "Danger full access"},
|
||||
}
|
||||
keyboard := make([][]InlineKeyboardButton, 0, len(options))
|
||||
for _, option := range options {
|
||||
label := option.Label
|
||||
if option.Value == current {
|
||||
label += " current"
|
||||
}
|
||||
keyboard = append(keyboard, []InlineKeyboardButton{{
|
||||
Text: label,
|
||||
CallbackData: SandboxCallbackData(option.Value),
|
||||
}})
|
||||
}
|
||||
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
||||
}
|
||||
|
||||
func sandboxStatusText(sandbox string) string {
|
||||
if strings.TrimSpace(sandbox) == "" {
|
||||
sandbox = "(Codex default)"
|
||||
}
|
||||
return "Current sandbox: " + sandbox
|
||||
}
|
||||
|
||||
func modelSupportsEffort(model codexapp.Model, effort string) bool {
|
||||
for _, option := range model.SupportedReasoningEfforts {
|
||||
if option.ReasoningEffort == effort {
|
||||
@@ -3281,13 +3405,140 @@ func renderApprovalDetailSectionsHTML(kind string, raw json.RawMessage) []string
|
||||
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
||||
return []string{CodeBlockHTML("json", string(raw))}
|
||||
}
|
||||
parts := renderSelectedArgumentDetailsHTML(params, []string{"cwd", "command", "additionalPermissions", "networkApprovalContext", "proposedExecpolicyAmendment", "proposedNetworkPolicyAmendments", "availableDecisions", "grantRoot", "permissions", "fileChanges", "parsedCmd", "reason"})
|
||||
parts := renderApprovalPayloadDetailsHTML(raw, params)
|
||||
if len(parts) == 0 {
|
||||
return []string{CodeBlockHTML("json", prettyJSON(raw))}
|
||||
}
|
||||
return nonEmptyHTML(parts)
|
||||
}
|
||||
|
||||
func renderApprovalPayloadDetailsHTML(raw json.RawMessage, params map[string]any) []string {
|
||||
var parts []string
|
||||
appendPart := func(part string) {
|
||||
if strings.TrimSpace(part) != "" {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
}
|
||||
|
||||
appendPart(renderApprovalFieldHTML("cwd", params["cwd"]))
|
||||
appendPart(renderApprovalFieldHTML("command", params["command"]))
|
||||
appendPart(renderApprovalFieldHTML("parsedCmd", params["parsedCmd"]))
|
||||
appendPart(renderApprovalFieldHTML("additionalPermissions", params["additionalPermissions"]))
|
||||
appendPart(renderNetworkApprovalContextHTML(params["networkApprovalContext"]))
|
||||
appendPart(renderExecpolicyAmendmentHTML(params["proposedExecpolicyAmendment"]))
|
||||
appendPart(renderNetworkPolicyAmendmentsHTML(params["proposedNetworkPolicyAmendments"]))
|
||||
appendPart(renderAvailableDecisionsHTML(raw))
|
||||
appendPart(renderApprovalFieldHTML("grantRoot", params["grantRoot"]))
|
||||
appendPart(renderApprovalFieldHTML("permissions", params["permissions"]))
|
||||
appendPart(renderApprovalFieldHTML("fileChanges", params["fileChanges"]))
|
||||
return parts
|
||||
}
|
||||
|
||||
func renderApprovalFieldHTML(key string, value any) string {
|
||||
if strings.EqualFold(key, "reason") {
|
||||
return ""
|
||||
}
|
||||
return renderArgumentFieldHTML(key, value)
|
||||
}
|
||||
|
||||
func renderExecpolicyAmendmentHTML(value any) string {
|
||||
items := stringSliceValue(value)
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "<b>Proposed command rule:</b> " + CodeBlockHTML("bash", strings.Join(items, " "))
|
||||
}
|
||||
|
||||
func renderNetworkPolicyAmendmentsHTML(value any) string {
|
||||
entries, ok := value.([]any)
|
||||
if !ok || len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, entry := range entries {
|
||||
object, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
action := strings.TrimSpace(fmt.Sprint(object["action"]))
|
||||
host := strings.TrimSpace(fmt.Sprint(object["host"]))
|
||||
if action == "" && host == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, strings.TrimSpace(strings.Join(nonEmptyStrings(action, host), " ")))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
return FieldHTML("Proposed network rules", strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func renderNetworkApprovalContextHTML(value any) string {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return ""
|
||||
}
|
||||
var fields []string
|
||||
for _, key := range []string{"host", "port", "protocol", "url"} {
|
||||
if text := strings.TrimSpace(fmt.Sprint(object[key])); text != "" && text != "<nil>" {
|
||||
fields = append(fields, key+"="+text)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return renderArgumentFieldHTML("networkApprovalContext", value)
|
||||
}
|
||||
return FieldHTML("Network request", strings.Join(fields, " "))
|
||||
}
|
||||
|
||||
func renderAvailableDecisionsHTML(raw json.RawMessage) string {
|
||||
options := approvalDecisionOptions(raw)
|
||||
if len(options) == 0 {
|
||||
return ""
|
||||
}
|
||||
seen := make(map[string]bool, len(options))
|
||||
var labels []string
|
||||
for _, option := range options {
|
||||
label := strings.TrimSpace(option.Label)
|
||||
if label == "" || seen[label] {
|
||||
continue
|
||||
}
|
||||
labels = append(labels, label)
|
||||
seen[label] = true
|
||||
}
|
||||
if len(labels) == 0 {
|
||||
return ""
|
||||
}
|
||||
return FieldHTML("Available decisions", strings.Join(labels, ", "))
|
||||
}
|
||||
|
||||
func stringSliceValue(value any) []string {
|
||||
switch v := value.(type) {
|
||||
case []string:
|
||||
return nonEmptyStrings(v...)
|
||||
case []any:
|
||||
var out []string
|
||||
for _, item := range v {
|
||||
text := strings.TrimSpace(fmt.Sprint(item))
|
||||
if text != "" && text != "<nil>" {
|
||||
out = append(out, text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmptyStrings(values ...string) []string {
|
||||
var out []string
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func approvalStatusLine(decision string) string {
|
||||
if strings.HasPrefix(decision, "networkPolicy") {
|
||||
return "Applied network rule."
|
||||
|
||||
Reference in New Issue
Block a user