Improve approval and sandbox flows
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
@@ -219,7 +220,7 @@ func botCommands() []BotCommand {
|
|||||||
{Command: "cancel", Description: "Interrupt the active turn"},
|
{Command: "cancel", Description: "Interrupt the active turn"},
|
||||||
{Command: "workspace", Description: "Select workspace"},
|
{Command: "workspace", Description: "Select workspace"},
|
||||||
{Command: "model", Description: "Choose model"},
|
{Command: "model", Description: "Choose model"},
|
||||||
{Command: "sandbox", Description: "Show or set sandbox"},
|
{Command: "sandbox", Description: "Choose sandbox"},
|
||||||
{Command: "pic", Description: "Generate images"},
|
{Command: "pic", Description: "Generate images"},
|
||||||
{Command: "diff", Description: "Show latest diff"},
|
{Command: "diff", Description: "Show latest diff"},
|
||||||
{Command: "help", Description: "Show help"},
|
{Command: "help", Description: "Show help"},
|
||||||
@@ -332,7 +333,7 @@ func (b *Bot) sendHelp(ctx context.Context, chatID int64) error {
|
|||||||
"/workspaces - list workspaces",
|
"/workspaces - list workspaces",
|
||||||
"/workspace [ID] - select workspace",
|
"/workspace [ID] - select workspace",
|
||||||
"/model - choose model and reasoning effort",
|
"/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",
|
"/pic PROMPT - generate image(s) from a prompt",
|
||||||
"/diff - show the latest streamed diff",
|
"/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 {
|
func (b *Bot) handleSandboxCommand(ctx context.Context, userID, chatID int64, session store.Session, args []string) error {
|
||||||
if len(args) == 0 {
|
_ = userID
|
||||||
_, err := b.tg.SendMessage(ctx, chatID, "Current sandbox: "+session.Sandbox, SendMessageOptions{})
|
if len(args) > 0 {
|
||||||
|
_, err := b.tg.SendMessage(ctx, chatID, "Use /sandbox and choose from the buttons.", SendMessageOptions{})
|
||||||
return err
|
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 {
|
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
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1186,6 +1211,9 @@ func (b *Bot) handleCallback(ctx context.Context, callback *CallbackQuery) error
|
|||||||
if effort, ok := ParseEffortCallbackData(callback.Data); ok {
|
if effort, ok := ParseEffortCallbackData(callback.Data); ok {
|
||||||
return b.handleEffortCallback(ctx, callback, effort)
|
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 {
|
if approvalID, decision, ok := ParseApprovalCallbackData(callback.Data); ok {
|
||||||
return b.handleApprovalCallback(ctx, callback, approvalID, decision)
|
return b.handleApprovalCallback(ctx, callback, approvalID, decision)
|
||||||
}
|
}
|
||||||
@@ -2202,10 +2230,76 @@ func combineToolApprovalHTML(toolHTML, approvalHTML string) string {
|
|||||||
case approvalHTML == "":
|
case approvalHTML == "":
|
||||||
return toolHTML
|
return toolHTML
|
||||||
default:
|
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 {
|
func addEditedAtLine(htmlText, editedAt string) string {
|
||||||
htmlText = strings.TrimSpace(htmlText)
|
htmlText = strings.TrimSpace(htmlText)
|
||||||
if htmlText == "" || editedAt == "" {
|
if htmlText == "" || editedAt == "" {
|
||||||
@@ -2907,6 +3001,36 @@ func effortMarkup(model codexapp.Model) *InlineKeyboardMarkup {
|
|||||||
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
|
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 {
|
func modelSupportsEffort(model codexapp.Model, effort string) bool {
|
||||||
for _, option := range model.SupportedReasoningEfforts {
|
for _, option := range model.SupportedReasoningEfforts {
|
||||||
if option.ReasoningEffort == effort {
|
if option.ReasoningEffort == effort {
|
||||||
@@ -3281,13 +3405,140 @@ func renderApprovalDetailSectionsHTML(kind string, raw json.RawMessage) []string
|
|||||||
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
||||||
return []string{CodeBlockHTML("json", string(raw))}
|
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 {
|
if len(parts) == 0 {
|
||||||
return []string{CodeBlockHTML("json", prettyJSON(raw))}
|
return []string{CodeBlockHTML("json", prettyJSON(raw))}
|
||||||
}
|
}
|
||||||
return nonEmptyHTML(parts)
|
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 {
|
func approvalStatusLine(decision string) string {
|
||||||
if strings.HasPrefix(decision, "networkPolicy") {
|
if strings.HasPrefix(decision, "networkPolicy") {
|
||||||
return "Applied network rule."
|
return "Applied network rule."
|
||||||
|
|||||||
@@ -443,6 +443,23 @@ func ParseApprovalCallbackData(data string) (int64, string, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SandboxCallbackData(sandbox string) string {
|
||||||
|
return "sandbox:" + sandbox
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseSandboxCallbackData(data string) (string, bool) {
|
||||||
|
if !strings.HasPrefix(data, "sandbox:") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
sandbox := strings.TrimPrefix(data, "sandbox:")
|
||||||
|
switch sandbox {
|
||||||
|
case "read-only", "workspace-write", "danger-full-access":
|
||||||
|
return sandbox, true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WorkspaceCallbackData(id int64) string {
|
func WorkspaceCallbackData(id int64) string {
|
||||||
return fmt.Sprintf("workspace:%d", id)
|
return fmt.Sprintf("workspace:%d", id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -371,6 +371,57 @@ func TestRenderApprovalDetailsAvoidsRawJSONDump(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderApprovalDetailsSummarizesPolicyFields(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{
|
||||||
|
"command":"git push gitea master",
|
||||||
|
"cwd":"/workspace/project",
|
||||||
|
"reason":"Need to publish changes",
|
||||||
|
"proposedExecpolicyAmendment":["git","push","gitea","master"],
|
||||||
|
"availableDecisions":[
|
||||||
|
{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["git","push","gitea","master"]}},
|
||||||
|
"decline"
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
text := renderApprovalHTML("item/commandExecution/requestApproval", raw, "")
|
||||||
|
for _, want := range []string{"Proposed command rule", "git push gitea master", "Available decisions", "Approve rule", "Deny"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("approval render missing concise field %q in %q", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{"execpolicy_amendment", "acceptWithExecpolicyAmendment", "availableDecisions"} {
|
||||||
|
if strings.Contains(text, unwanted) {
|
||||||
|
t.Fatalf("approval render still contains verbose field %q in %q", unwanted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Count(text, "Need to publish changes") != 1 {
|
||||||
|
t.Fatalf("reason should only appear once in summary: %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCombinedCommandApprovalOmitsDuplicateCommandDetails(t *testing.T) {
|
||||||
|
toolHTML := renderCodexItemStarted(codexThreadItemView{
|
||||||
|
Type: "commandExecution",
|
||||||
|
Command: "go test ./...",
|
||||||
|
CWD: "/workspace/project",
|
||||||
|
})
|
||||||
|
approvalHTML := renderApprovalHTML("item/commandExecution/requestApproval", json.RawMessage(`{
|
||||||
|
"command":"go test ./...",
|
||||||
|
"cwd":"/workspace/project",
|
||||||
|
"reason":"Need to run tests"
|
||||||
|
}`), "")
|
||||||
|
text := combineToolApprovalHTML(toolHTML, approvalHTML)
|
||||||
|
for _, want := range []string{"Tool call: command started", "Codex requests command approval", "Need to run tests"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("combined approval missing %q in %q", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, duplicate := range []string{"<b>CWD:</b>", "<b>Command:</b>"} {
|
||||||
|
if got := strings.Count(text, duplicate); got != 1 {
|
||||||
|
t.Fatalf("%s count = %d, want 1 in %q", duplicate, got, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApprovalOnlyToolMessageCanReceiveCompletionDetails(t *testing.T) {
|
func TestApprovalOnlyToolMessageCanReceiveCompletionDetails(t *testing.T) {
|
||||||
exitCode := 0
|
exitCode := 0
|
||||||
duration := int64(1234)
|
duration := int64(1234)
|
||||||
@@ -464,7 +515,7 @@ func TestResumeThreadListText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModelAndEffortCallbackData(t *testing.T) {
|
func TestModelEffortAndSandboxCallbackData(t *testing.T) {
|
||||||
modelID := strings.Join([]string{"server", "model", "id"}, "-")
|
modelID := strings.Join([]string{"server", "model", "id"}, "-")
|
||||||
data, ok := ModelCallbackData(modelID)
|
data, ok := ModelCallbackData(modelID)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -479,4 +530,11 @@ func TestModelAndEffortCallbackData(t *testing.T) {
|
|||||||
if !ok || effort != effortName {
|
if !ok || effort != effortName {
|
||||||
t.Fatalf("unexpected effort callback parse: effort=%q ok=%v", effort, ok)
|
t.Fatalf("unexpected effort callback parse: effort=%q ok=%v", effort, ok)
|
||||||
}
|
}
|
||||||
|
sandbox, ok := ParseSandboxCallbackData(SandboxCallbackData("workspace-write"))
|
||||||
|
if !ok || sandbox != "workspace-write" {
|
||||||
|
t.Fatalf("unexpected sandbox callback parse: sandbox=%q ok=%v", sandbox, ok)
|
||||||
|
}
|
||||||
|
if _, ok := ParseSandboxCallbackData(SandboxCallbackData("bad")); ok {
|
||||||
|
t.Fatal("bad sandbox callback should not parse")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SYSTEM_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
if [[ -n "${PATH:-}" ]]; then
|
||||||
|
PATH="$PATH:$SYSTEM_PATH"
|
||||||
|
else
|
||||||
|
PATH="$SYSTEM_PATH"
|
||||||
|
fi
|
||||||
|
export PATH
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
ENV_FILE="$ROOT/.env"
|
ENV_FILE="$ROOT/.env"
|
||||||
RUN_DIR="$ROOT/run"
|
RUN_DIR="$ROOT/run"
|
||||||
@@ -301,7 +309,9 @@ import json
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
target, path = sys.argv[1], sys.argv[2]
|
target, path = sys.argv[1], sys.argv[2]
|
||||||
asset_name = f"codex-{target}.tar.gz"
|
needs_bwrap = "linux" in target
|
||||||
|
codex_asset_name = f"codex-{target}.tar.gz"
|
||||||
|
bwrap_asset_name = f"bwrap-{target}.tar.gz" if needs_bwrap else None
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
release = json.load(f)
|
release = json.load(f)
|
||||||
tag = release.get("tag_name", "")
|
tag = release.get("tag_name", "")
|
||||||
@@ -310,15 +320,26 @@ if version.startswith("rust-v"):
|
|||||||
version = version[6:]
|
version = version[6:]
|
||||||
elif version.startswith("v"):
|
elif version.startswith("v"):
|
||||||
version = version[1:]
|
version = version[1:]
|
||||||
for asset in release.get("assets", []):
|
assets = {asset.get("name"): asset for asset in release.get("assets", [])}
|
||||||
if asset.get("name") == asset_name:
|
codex_asset = assets.get(codex_asset_name)
|
||||||
print(version)
|
bwrap_asset = assets.get(bwrap_asset_name) if needs_bwrap else None
|
||||||
print(tag)
|
required_assets = [(codex_asset_name, codex_asset)]
|
||||||
print(asset.get("browser_download_url", ""))
|
if needs_bwrap:
|
||||||
print(asset.get("digest", ""))
|
required_assets.append((bwrap_asset_name, bwrap_asset))
|
||||||
raise SystemExit(0)
|
missing = [name for name, asset in required_assets if asset is None]
|
||||||
print(f"release {tag or '<unknown>'} has no asset named {asset_name}", file=sys.stderr)
|
if missing:
|
||||||
raise SystemExit(1)
|
print(f"release {tag or '<unknown>'} has no asset named {', '.join(missing)}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(codex_asset.get("browser_download_url", ""))
|
||||||
|
print(codex_asset.get("digest", ""))
|
||||||
|
if bwrap_asset is not None:
|
||||||
|
print(bwrap_asset.get("browser_download_url", ""))
|
||||||
|
print(bwrap_asset.get("digest", ""))
|
||||||
|
else:
|
||||||
|
print("")
|
||||||
|
print("")
|
||||||
|
print(version)
|
||||||
|
print(tag)
|
||||||
PY
|
PY
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +382,38 @@ extract_codex_binary() {
|
|||||||
printf '%s\n' "$found"
|
printf '%s\n' "$found"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extract_bwrap_binary() {
|
||||||
|
local archive="$1" dest="$2" found
|
||||||
|
mkdir -p "$dest/bwrap-extract"
|
||||||
|
tar -xzf "$archive" -C "$dest/bwrap-extract"
|
||||||
|
found="$(find "$dest/bwrap-extract" -type f -name bwrap -print | head -n 1)"
|
||||||
|
if [[ -z "$found" ]]; then
|
||||||
|
found="$(find "$dest/bwrap-extract" -type f -name 'bwrap-*' -perm -u+x -print | head -n 1)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$found" ]]; then
|
||||||
|
echo "downloaded archive does not contain a bwrap executable" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
chmod +x "$found"
|
||||||
|
printf '%s\n' "$found"
|
||||||
|
}
|
||||||
|
|
||||||
|
bundled_bwrap_path() {
|
||||||
|
local bin="$1"
|
||||||
|
printf '%s/codex-resources/bwrap\n' "$(dirname "$bin")"
|
||||||
|
}
|
||||||
|
|
||||||
|
bundled_bwrap_installed() {
|
||||||
|
local bin="$1" bundled
|
||||||
|
bundled="$(bundled_bwrap_path "$bin")"
|
||||||
|
[[ -x "$bundled" && ! -L "$bundled" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
bwrap_required_for_target() {
|
||||||
|
[[ "$1" == *linux* ]]
|
||||||
|
}
|
||||||
|
|
||||||
run_install() {
|
run_install() {
|
||||||
if [[ "${#INSTALL_PREFIX[@]}" -gt 0 ]]; then
|
if [[ "${#INSTALL_PREFIX[@]}" -gt 0 ]]; then
|
||||||
"${INSTALL_PREFIX[@]}" "$@"
|
"${INSTALL_PREFIX[@]}" "$@"
|
||||||
@@ -385,16 +438,37 @@ choose_install_prefix() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
install_bundled_bwrap() {
|
||||||
|
local candidate="$1" bin="$2" backup="$3" bundled resources_dir tmp_new bwrap_backup bwrap_missing
|
||||||
|
bundled="$(bundled_bwrap_path "$bin")"
|
||||||
|
resources_dir="$(dirname "$bundled")"
|
||||||
|
tmp_new="$bundled.new.$$"
|
||||||
|
bwrap_backup="$backup.bwrap"
|
||||||
|
bwrap_missing="$backup.bwrap.missing"
|
||||||
|
|
||||||
|
run_install mkdir -p "$resources_dir"
|
||||||
|
if [[ -e "$bundled" ]]; then
|
||||||
|
run_install cp -p "$bundled" "$bwrap_backup"
|
||||||
|
else
|
||||||
|
run_install touch "$bwrap_missing"
|
||||||
|
fi
|
||||||
|
run_install install -m 0755 "$candidate" "$tmp_new"
|
||||||
|
run_install mv -f "$tmp_new" "$bundled"
|
||||||
|
}
|
||||||
|
|
||||||
install_candidate() {
|
install_candidate() {
|
||||||
local candidate="$1" bin="$2" backup="$3" tmp_new="$bin.new.$$"
|
local candidate="$1" bwrap_candidate="$2" bin="$3" backup="$4" tmp_new="$bin.new.$$"
|
||||||
choose_install_prefix "$bin"
|
choose_install_prefix "$bin"
|
||||||
run_install cp -p "$bin" "$backup"
|
run_install cp -p "$bin" "$backup"
|
||||||
run_install install -m 0755 "$candidate" "$tmp_new"
|
run_install install -m 0755 "$candidate" "$tmp_new"
|
||||||
run_install mv -f "$tmp_new" "$bin"
|
run_install mv -f "$tmp_new" "$bin"
|
||||||
|
if [[ -n "$bwrap_candidate" ]]; then
|
||||||
|
install_bundled_bwrap "$bwrap_candidate" "$bin" "$backup"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
restore_backup() {
|
restore_backup() {
|
||||||
local bin="$1" backup="$2" tmp_failed="$bin.failed.$$"
|
local bin="$1" backup="$2" tmp_failed="$bin.failed.$$" bundled bwrap_backup bwrap_missing
|
||||||
if [[ ! -e "$backup" ]]; then
|
if [[ ! -e "$backup" ]]; then
|
||||||
echo "backup missing; cannot restore $bin" >&2
|
echo "backup missing; cannot restore $bin" >&2
|
||||||
return 1
|
return 1
|
||||||
@@ -404,6 +478,17 @@ restore_backup() {
|
|||||||
run_install mv -f "$bin" "$tmp_failed" || true
|
run_install mv -f "$bin" "$tmp_failed" || true
|
||||||
fi
|
fi
|
||||||
run_install mv -f "$backup" "$bin"
|
run_install mv -f "$backup" "$bin"
|
||||||
|
|
||||||
|
bundled="$(bundled_bwrap_path "$bin")"
|
||||||
|
bwrap_backup="$backup.bwrap"
|
||||||
|
bwrap_missing="$backup.bwrap.missing"
|
||||||
|
if [[ -e "$bwrap_backup" ]]; then
|
||||||
|
run_install mkdir -p "$(dirname "$bundled")"
|
||||||
|
run_install mv -f "$bwrap_backup" "$bundled"
|
||||||
|
elif [[ -e "$bwrap_missing" ]]; then
|
||||||
|
run_install rm -f "$bundled"
|
||||||
|
run_install rm -f "$bwrap_missing"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
confirm_upgrade() {
|
confirm_upgrade() {
|
||||||
@@ -421,13 +506,13 @@ confirm_upgrade() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_upgrade() {
|
apply_upgrade() {
|
||||||
local candidate="$1" bin="$2" backup="$3" local_version="$4" latest_version="$5" was_running=0
|
local candidate="$1" bwrap_candidate="$2" bin="$3" backup="$4" local_version="$5" latest_version="$6" was_running=0
|
||||||
if is_running; then
|
if is_running; then
|
||||||
was_running=1
|
was_running=1
|
||||||
stop_server
|
stop_server
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! install_candidate "$candidate" "$bin" "$backup"; then
|
if ! install_candidate "$candidate" "$bwrap_candidate" "$bin" "$backup"; then
|
||||||
echo "failed to install Codex update" >&2
|
echo "failed to install Codex update" >&2
|
||||||
if [[ "$was_running" == "1" ]]; then
|
if [[ "$was_running" == "1" ]]; then
|
||||||
start_server || true
|
start_server || true
|
||||||
@@ -449,12 +534,12 @@ apply_upgrade() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handoff_upgrade() {
|
handoff_upgrade() {
|
||||||
local candidate="$1" bin="$2" backup="$3" update_dir="$4" local_version="$5" latest_version="$6"
|
local candidate="$1" bwrap_candidate="$2" bin="$3" backup="$4" update_dir="$5" local_version="$6" latest_version="$7"
|
||||||
: > "$UPGRADE_LOG_FILE"
|
: > "$UPGRADE_LOG_FILE"
|
||||||
setsid -f bash -c '
|
setsid -f bash -c '
|
||||||
sleep 1
|
sleep 1
|
||||||
"$0" __apply-upgrade "$1" "$2" "$3" "$4" "$5" "$6"
|
"$0" __apply-upgrade "$1" "$2" "$3" "$4" "$5" "$6" "$7"
|
||||||
' "$0" "$candidate" "$bin" "$backup" "$update_dir" "$local_version" "$latest_version" >> "$UPGRADE_LOG_FILE" 2>&1
|
' "$0" "$candidate" "$bwrap_candidate" "$bin" "$backup" "$update_dir" "$local_version" "$latest_version" >> "$UPGRADE_LOG_FILE" 2>&1
|
||||||
echo "Codex upgrade handoff started; app-server will restart if replacement succeeds. log=$UPGRADE_LOG_FILE"
|
echo "Codex upgrade handoff started; app-server will restart if replacement succeeds. log=$UPGRADE_LOG_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,7 +559,8 @@ check_updates() {
|
|||||||
require_cmd python3
|
require_cmd python3
|
||||||
require_cmd ps
|
require_cmd ps
|
||||||
|
|
||||||
local bin local_version target json latest_version latest_tag download_url digest archive tmp candidate candidate_version backup
|
local bin local_version target json latest_version latest_tag codex_download_url codex_digest bwrap_download_url bwrap_digest
|
||||||
|
local codex_archive bwrap_archive tmp candidate bwrap_candidate candidate_version backup
|
||||||
bin="$(codex_bin)"
|
bin="$(codex_bin)"
|
||||||
if [[ -z "$bin" ]]; then
|
if [[ -z "$bin" ]]; then
|
||||||
echo "codex executable not found; set CODEX_BIN" >&2
|
echo "codex executable not found; set CODEX_BIN" >&2
|
||||||
@@ -495,28 +581,60 @@ check_updates() {
|
|||||||
json="$tmp/latest.json"
|
json="$tmp/latest.json"
|
||||||
curl -fsSL "https://api.github.com/repos/$CODEX_RELEASE_REPO/releases/latest" -o "$json"
|
curl -fsSL "https://api.github.com/repos/$CODEX_RELEASE_REPO/releases/latest" -o "$json"
|
||||||
mapfile -t release_info < <(latest_release_info "$target" "$json")
|
mapfile -t release_info < <(latest_release_info "$target" "$json")
|
||||||
latest_version="${release_info[0]:-}"
|
codex_download_url="${release_info[0]:-}"
|
||||||
latest_tag="${release_info[1]:-}"
|
codex_digest="${release_info[1]:-}"
|
||||||
download_url="${release_info[2]:-}"
|
bwrap_download_url="${release_info[2]:-}"
|
||||||
digest="${release_info[3]:-}"
|
bwrap_digest="${release_info[3]:-}"
|
||||||
if [[ -z "$latest_version" || -z "$download_url" ]]; then
|
latest_version="${release_info[4]:-}"
|
||||||
|
latest_tag="${release_info[5]:-}"
|
||||||
|
if [[ -z "$latest_version" || -z "$codex_download_url" ]]; then
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
echo "could not determine latest Codex release for $target" >&2
|
echo "could not determine latest Codex release for $target" >&2
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
if bwrap_required_for_target "$target" && [[ -z "$bwrap_download_url" ]]; then
|
||||||
|
rm -rf "$tmp"
|
||||||
|
echo "could not determine latest bundled bwrap release for $target" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
if ! version_gt "$latest_version" "$local_version"; then
|
if ! version_gt "$latest_version" "$local_version"; then
|
||||||
|
if ! bwrap_required_for_target "$target" || bundled_bwrap_installed "$bin"; then
|
||||||
|
rm -rf "$tmp"
|
||||||
|
echo "Codex is already current: $local_version (latest $latest_version)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Codex is already current: $local_version (latest $latest_version); installing missing bundled bwrap"
|
||||||
|
bwrap_archive="$tmp/bwrap-$target.tar.gz"
|
||||||
|
curl -fL "$bwrap_download_url" -o "$bwrap_archive"
|
||||||
|
verify_digest "$bwrap_archive" "$bwrap_digest"
|
||||||
|
bwrap_candidate="$(extract_bwrap_binary "$bwrap_archive" "$tmp")"
|
||||||
|
backup="$bin.bak.$(date -u +%Y%m%d%H%M%S)"
|
||||||
|
choose_install_prefix "$bin"
|
||||||
|
if install_bundled_bwrap "$bwrap_candidate" "$bin" "$backup"; then
|
||||||
|
run_install rm -f "$backup.bwrap.missing"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
echo "Bundled bwrap installed: $(bundled_bwrap_path "$bin")"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
echo "Codex is already current: $local_version (latest $latest_version)"
|
return 1
|
||||||
return 0
|
|
||||||
fi
|
fi
|
||||||
echo "Codex update available: $local_version -> $latest_version ($latest_tag)"
|
echo "Codex update available: $local_version -> $latest_version ($latest_tag)"
|
||||||
confirm_upgrade "$local_version" "$latest_version" "$bin"
|
confirm_upgrade "$local_version" "$latest_version" "$bin"
|
||||||
|
|
||||||
archive="$tmp/codex-$target.tar.gz"
|
codex_archive="$tmp/codex-$target.tar.gz"
|
||||||
curl -fL "$download_url" -o "$archive"
|
curl -fL "$codex_download_url" -o "$codex_archive"
|
||||||
verify_digest "$archive" "$digest"
|
verify_digest "$codex_archive" "$codex_digest"
|
||||||
candidate="$(extract_codex_binary "$archive" "$tmp")"
|
candidate="$(extract_codex_binary "$codex_archive" "$tmp")"
|
||||||
|
if bwrap_required_for_target "$target"; then
|
||||||
|
bwrap_archive="$tmp/bwrap-$target.tar.gz"
|
||||||
|
curl -fL "$bwrap_download_url" -o "$bwrap_archive"
|
||||||
|
verify_digest "$bwrap_archive" "$bwrap_digest"
|
||||||
|
bwrap_candidate="$(extract_bwrap_binary "$bwrap_archive" "$tmp")"
|
||||||
|
else
|
||||||
|
bwrap_candidate=""
|
||||||
|
fi
|
||||||
candidate_version="$(codex_version_from "$candidate")"
|
candidate_version="$(codex_version_from "$candidate")"
|
||||||
if [[ "$candidate_version" != "$latest_version" ]]; then
|
if [[ "$candidate_version" != "$latest_version" ]]; then
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
@@ -528,11 +646,11 @@ check_updates() {
|
|||||||
choose_install_prefix "$bin"
|
choose_install_prefix "$bin"
|
||||||
|
|
||||||
if is_running; then
|
if is_running; then
|
||||||
handoff_upgrade "$candidate" "$bin" "$backup" "$tmp" "$local_version" "$latest_version"
|
handoff_upgrade "$candidate" "$bwrap_candidate" "$bin" "$backup" "$tmp" "$local_version" "$latest_version"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if apply_upgrade "$candidate" "$bin" "$backup" "$local_version" "$latest_version"; then
|
if apply_upgrade "$candidate" "$bwrap_candidate" "$bin" "$backup" "$local_version" "$latest_version"; then
|
||||||
rm -rf "$tmp"
|
rm -rf "$tmp"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -541,13 +659,18 @@ check_updates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply_upgrade_worker() {
|
apply_upgrade_worker() {
|
||||||
local candidate="$1" bin="$2" backup="$3" update_dir="$4" local_version="$5" latest_version="$6" rc=0
|
local candidate="$1" bwrap_candidate="$2" bin="$3" backup="$4" update_dir="$5" local_version="$6" latest_version="$7" rc=0
|
||||||
if [[ ! -x "$candidate" ]]; then
|
if [[ ! -x "$candidate" ]]; then
|
||||||
echo "upgrade candidate is missing or not executable: $candidate" >&2
|
echo "upgrade candidate is missing or not executable: $candidate" >&2
|
||||||
rm -rf "$update_dir"
|
rm -rf "$update_dir"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if ! apply_upgrade "$candidate" "$bin" "$backup" "$local_version" "$latest_version"; then
|
if [[ -n "$bwrap_candidate" && ! -x "$bwrap_candidate" ]]; then
|
||||||
|
echo "bundled bwrap candidate is missing or not executable: $bwrap_candidate" >&2
|
||||||
|
rm -rf "$update_dir"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! apply_upgrade "$candidate" "$bwrap_candidate" "$bin" "$backup" "$local_version" "$latest_version"; then
|
||||||
rc=1
|
rc=1
|
||||||
fi
|
fi
|
||||||
rm -rf "$update_dir"
|
rm -rf "$update_dir"
|
||||||
@@ -577,7 +700,7 @@ case "$cmd" in
|
|||||||
;;
|
;;
|
||||||
__apply-upgrade)
|
__apply-upgrade)
|
||||||
shift || true
|
shift || true
|
||||||
if [[ $# -ne 6 ]]; then echo "invalid upgrade worker arguments" >&2; exit 2; fi
|
if [[ $# -ne 7 ]]; then echo "invalid upgrade worker arguments" >&2; exit 2; fi
|
||||||
apply_upgrade_worker "$@"
|
apply_upgrade_worker "$@"
|
||||||
;;
|
;;
|
||||||
-h|--help|help)
|
-h|--help|help)
|
||||||
|
|||||||
Reference in New Issue
Block a user