diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 40c023c..2309db2 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -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, ""+EscapeHTML(label)+":") { + 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 = "
" + const close = "
" + 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 = "" + const labelClose = ":" + 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 "Proposed command rule: " + 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 != "" { + 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 != "" { + 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." diff --git a/internal/telegram/render.go b/internal/telegram/render.go index 03b0ed2..b023927 100644 --- a/internal/telegram/render.go +++ b/internal/telegram/render.go @@ -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 { return fmt.Sprintf("workspace:%d", id) } diff --git a/internal/telegram/render_test.go b/internal/telegram/render_test.go index ccdc0d6..c94a5b6 100644 --- a/internal/telegram/render_test.go +++ b/internal/telegram/render_test.go @@ -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{"CWD:", "Command:"} { + 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) { exitCode := 0 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"}, "-") data, ok := ModelCallbackData(modelID) if !ok { @@ -479,4 +530,11 @@ func TestModelAndEffortCallbackData(t *testing.T) { if !ok || effort != effortName { 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") + } } diff --git a/scripts/start-codex-app-server b/scripts/start-codex-app-server index dd13623..aef48a4 100755 --- a/scripts/start-codex-app-server +++ b/scripts/start-codex-app-server @@ -1,6 +1,14 @@ #!/usr/bin/env bash 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)" ENV_FILE="$ROOT/.env" RUN_DIR="$ROOT/run" @@ -301,7 +309,9 @@ import json import sys 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: release = json.load(f) tag = release.get("tag_name", "") @@ -310,15 +320,26 @@ if version.startswith("rust-v"): version = version[6:] elif version.startswith("v"): version = version[1:] -for asset in release.get("assets", []): - if asset.get("name") == asset_name: - print(version) - print(tag) - print(asset.get("browser_download_url", "")) - print(asset.get("digest", "")) - raise SystemExit(0) -print(f"release {tag or ''} has no asset named {asset_name}", file=sys.stderr) -raise SystemExit(1) +assets = {asset.get("name"): asset for asset in release.get("assets", [])} +codex_asset = assets.get(codex_asset_name) +bwrap_asset = assets.get(bwrap_asset_name) if needs_bwrap else None +required_assets = [(codex_asset_name, codex_asset)] +if needs_bwrap: + required_assets.append((bwrap_asset_name, bwrap_asset)) +missing = [name for name, asset in required_assets if asset is None] +if missing: + print(f"release {tag or ''} 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 } @@ -361,6 +382,38 @@ extract_codex_binary() { 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() { if [[ "${#INSTALL_PREFIX[@]}" -gt 0 ]]; then "${INSTALL_PREFIX[@]}" "$@" @@ -385,16 +438,37 @@ choose_install_prefix() { 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() { - 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" run_install cp -p "$bin" "$backup" run_install install -m 0755 "$candidate" "$tmp_new" run_install mv -f "$tmp_new" "$bin" + if [[ -n "$bwrap_candidate" ]]; then + install_bundled_bwrap "$bwrap_candidate" "$bin" "$backup" + fi } 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 echo "backup missing; cannot restore $bin" >&2 return 1 @@ -404,6 +478,17 @@ restore_backup() { run_install mv -f "$bin" "$tmp_failed" || true fi 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() { @@ -421,13 +506,13 @@ confirm_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 was_running=1 stop_server fi - if ! install_candidate "$candidate" "$bin" "$backup"; then + if ! install_candidate "$candidate" "$bwrap_candidate" "$bin" "$backup"; then echo "failed to install Codex update" >&2 if [[ "$was_running" == "1" ]]; then start_server || true @@ -449,12 +534,12 @@ apply_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" setsid -f bash -c ' sleep 1 - "$0" __apply-upgrade "$1" "$2" "$3" "$4" "$5" "$6" - ' "$0" "$candidate" "$bin" "$backup" "$update_dir" "$local_version" "$latest_version" >> "$UPGRADE_LOG_FILE" 2>&1 + "$0" __apply-upgrade "$1" "$2" "$3" "$4" "$5" "$6" "$7" + ' "$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" } @@ -474,7 +559,8 @@ check_updates() { require_cmd python3 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)" if [[ -z "$bin" ]]; then echo "codex executable not found; set CODEX_BIN" >&2 @@ -495,28 +581,60 @@ check_updates() { json="$tmp/latest.json" curl -fsSL "https://api.github.com/repos/$CODEX_RELEASE_REPO/releases/latest" -o "$json" mapfile -t release_info < <(latest_release_info "$target" "$json") - latest_version="${release_info[0]:-}" - latest_tag="${release_info[1]:-}" - download_url="${release_info[2]:-}" - digest="${release_info[3]:-}" - if [[ -z "$latest_version" || -z "$download_url" ]]; then + codex_download_url="${release_info[0]:-}" + codex_digest="${release_info[1]:-}" + bwrap_download_url="${release_info[2]:-}" + bwrap_digest="${release_info[3]:-}" + latest_version="${release_info[4]:-}" + latest_tag="${release_info[5]:-}" + if [[ -z "$latest_version" || -z "$codex_download_url" ]]; then rm -rf "$tmp" echo "could not determine latest Codex release for $target" >&2 return 1 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 ! 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" - echo "Codex is already current: $local_version (latest $latest_version)" - return 0 + return 1 fi echo "Codex update available: $local_version -> $latest_version ($latest_tag)" confirm_upgrade "$local_version" "$latest_version" "$bin" - archive="$tmp/codex-$target.tar.gz" - curl -fL "$download_url" -o "$archive" - verify_digest "$archive" "$digest" - candidate="$(extract_codex_binary "$archive" "$tmp")" + codex_archive="$tmp/codex-$target.tar.gz" + curl -fL "$codex_download_url" -o "$codex_archive" + verify_digest "$codex_archive" "$codex_digest" + 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")" if [[ "$candidate_version" != "$latest_version" ]]; then rm -rf "$tmp" @@ -528,11 +646,11 @@ check_updates() { choose_install_prefix "$bin" 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 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" return 0 fi @@ -541,13 +659,18 @@ check_updates() { } 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 echo "upgrade candidate is missing or not executable: $candidate" >&2 rm -rf "$update_dir" return 1 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 fi rm -rf "$update_dir" @@ -577,7 +700,7 @@ case "$cmd" in ;; __apply-upgrade) 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 "$@" ;; -h|--help|help)