From 8802f141c14447ad4d889daffe9d6e57f768453a Mon Sep 17 00:00:00 2001 From: AidenGeunGeun Date: Sat, 22 Aug 2026 07:40:42 +0900 Subject: [PATCH] fix(tui): auto-approve background tab permissions --- packages/tui/src/context/session-tabs.tsx | 41 ++++++++-- packages/tui/src/routes/session/index.tsx | 18 ----- .../tui/test/context/session-tabs.test.tsx | 77 ++++++++++++++----- 3 files changed, 92 insertions(+), 44 deletions(-) diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 1878b6122805..4ec225062527 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -11,6 +11,7 @@ import { useConfig } from "../config" import { useLocation } from "./location" import { useStorage } from "./storage" import { useTuiPaths } from "./runtime" +import { usePermission } from "./permission" import { newSessionLocation } from "../config/new-session-location" import { closeSessionTab, @@ -59,6 +60,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const location = useLocation() const paths = useTuiPaths() const renderer = useRenderer() + const permission = usePermission() const enabled = () => config.tabs.enabled // Focus reporting emits transitions, so an interactive launch owns unread state until its first blur. const [focused, setFocused] = createSignal(true) @@ -124,6 +126,28 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, {}), }) const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined) + const autoApproved = new Set() + createEffect(() => { + if (permission.mode !== "auto") return + const active = current() + const roots = [ + ...(active ? [active] : []), + ...(enabled() ? state().tabs.map((tab) => root(tab.sessionID)) : []), + ] + const sessions = Array.from(new Set(roots.flatMap((sessionID) => [sessionID, ...data.session.family(sessionID)]))) + sessions + .flatMap((sessionID) => data.session.permission.list(sessionID) ?? []) + .forEach((request) => { + if (autoApproved.has(request.id)) return + autoApproved.add(request.id) + void data.session.permission + .reply({ sessionID: request.sessionID, reply: "once", requestID: request.id }) + .catch((error) => { + console.error("Failed to auto-approve permission", error) + }) + .finally(() => autoApproved.delete(request.id)) + }) + }) const newTab = createMemo((open = false) => { if (route.data.type === "home") return true if (!open) return false @@ -229,16 +253,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp ) })() const timer = setTimeout(async () => { - const sessions = state() - .tabs.map((tab) => tab.sessionID) - .filter((sessionID) => sessionID !== current()) + const sessions = state().tabs.map((tab) => tab.sessionID) for (const sessionID of sessions) { if (stale) return + const family = Array.from(new Set([sessionID, ...data.session.family(sessionID)])) await Promise.allSettled([ - data.session.message.sync(sessionID), - data.session.pending.sync(sessionID), - data.session.permission.sync(sessionID), - data.session.form.sync(sessionID), + ...(sessionID === current() + ? [] + : [ + data.session.message.sync(sessionID), + data.session.pending.sync(sessionID), + data.session.form.sync(sessionID), + ]), + ...family.map((id) => data.session.permission.sync(id)), ]) } }, TAB_PREFETCH_DELAY) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c82c7185470f..b83e9b051ca3 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -247,24 +247,6 @@ export function Session(props: { verticalTabsWidth: number }) { const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) const toast = useToast() const client = useClient() - const autoApproved = new Set() - createEffect(() => { - if (local.permission.mode !== "auto") return - permissions().forEach((request) => { - if (autoApproved.has(request.id)) return - autoApproved.add(request.id) - void data.session.permission - .reply({ - sessionID: request.sessionID, - reply: "once", - requestID: request.id, - }) - .catch((error) => { - autoApproved.delete(request.id) - toast.error(error) - }) - }) - }) const editor = useEditorContext() const [rowsSynced, setRowsSynced] = createSignal(false) const rows = createSessionRows( diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index cc3ec7d6c2e3..3f7293d19153 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -5,9 +5,11 @@ import { testRender } from "@opentui/solid" import { mkdirSync, watch } from "fs" import path from "path" import { ConfigProvider } from "../../src/config" +import { ArgsProvider } from "../../src/context/args" import { ClientProvider, useClient } from "../../src/context/client" import { DataProvider, useData } from "../../src/context/data" import { LocationProvider } from "../../src/context/location" +import { PermissionProvider } from "../../src/context/permission" import { RouteProvider, useRoute } from "../../src/context/route" import { TuiAppProvider } from "../../src/context/runtime" import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs" @@ -36,6 +38,7 @@ async function renderSessionTabs( sessionGate?: Promise sessionDirectories?: Record newLocation?: "launch" | "inherit" + auto?: boolean }, ) { const temporary = options?.state ? undefined : await tmpdir() @@ -55,6 +58,7 @@ async function renderSessionTabs( const sessions: string[] = [] const locations: string[] = [] const vcsLocations: string[] = [] + const permissionReplies: string[] = [] const calls = createFetch(async (url) => { if (url.pathname === "/api/location") { const requested = url.searchParams.get("location[directory]") ?? directory @@ -72,6 +76,11 @@ async function renderSessionTabs( data: { branch: { current: "main", default: "main" } }, }) } + const permissionReply = url.pathname.match(/^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/)?.[1] + if (permissionReply) { + permissionReplies.push(permissionReply) + return new Response(null, { status: 204 }) + } const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1] if (!sessionID) return undefined sessions.push(sessionID) @@ -107,26 +116,30 @@ async function renderSessionTabs( - - + - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -140,6 +153,7 @@ async function renderSessionTabs( sessions, locations, vcsLocations, + permissionReplies, state, emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), focus: () => app.renderer.emit("focus"), @@ -153,6 +167,31 @@ async function renderSessionTabs( } } +test("auto-approves a permission requested by a background tab", async () => { + const setup = await renderSessionTabs("background", { auto: true }) + try { + await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background")) + setup.route.navigate({ type: "session", sessionID: "active" }) + await wait(() => setup.tabs.current() === "active" && setup.tabs.tabs().length === 2) + + setup.emit({ + id: "evt_permission_background", + created: Date.now(), + type: "permission.asked", + data: { + id: "per_background", + sessionID: "background", + action: "external_directory", + resources: ["/tmp/background-worktree/*"], + }, + }) + + await wait(() => setup.permissionReplies.includes("per_background")) + } finally { + await setup.destroy() + } +}) + const executionSucceeded = (sessionID: string): OpenCodeEvent => ({ id: `evt_done_${sessionID}`, created: Date.now(),