Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix(app): retry auto-accepted permissions
  • Loading branch information
ProdigyRahul committed Sep 4, 2026
commit 04870fb84e979d3dc876f90d47eae202f3c3f2d7
68 changes: 64 additions & 4 deletions packages/app/e2e/regression/remote-session-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,34 @@ const sessionA = session("ses_server_a", directoryA, "Server A session")
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
const sessionB = session("ses_server_b", directoryB, "Server B session")

test("session settings use the remote server context", async ({ page }) => {
test("auto-accept setting works without a session", async ({ page }) => {
const permissionRequests: string[] = []
await mockServers(page, permissionRequests)
await configureServers(page)

await page.goto("/")
await page.keyboard.press("Control+,")

const input = page
.locator(".settings-v2-dialog")
.locator('[data-action="settings-auto-accept-permissions"]')
.getByRole("switch")
await expect(input).toBeEnabled()
await input.click()
await expect(input).toBeChecked()
})

test("session settings use the remote server context", async ({ page }) => {
const permissionRequests: string[] = []
const permissionResponses: PermissionResponse[] = []
const pendingB: MockPermission[] = []
const permissionID = "permission-pending-b"
await mockServers(page, permissionRequests, permissionResponses, {
pending: { [serverB]: pendingB },
replyFailures: { [permissionID]: 1 },
})
await configureServers(page)

await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
await expect(page.getByText(sessionB.title).first()).toBeVisible()
await page.keyboard.press("Control+,")
Expand All @@ -26,6 +49,7 @@ test("session settings use the remote server context", async ({ page }) => {
await expect(autoAccept).toBeVisible()
await expect(input).toBeEnabled()
permissionRequests.length = 0
pendingB.push(pendingPermission(permissionID, sessionB.id))
await autoAccept.locator('[data-slot="switch-control"]').click()
await expect(input).toBeChecked()
await expect
Expand All @@ -36,7 +60,17 @@ test("session settings use the remote server context", async ({ page }) => {
}),
)
.toBe(true)
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
await expect
.poll(() => permissionResponses)
.toEqual([
{
origin: serverB,
directory: directoryB,
sessionID: sessionB.id,
permissionID,
body: { response: "once" },
},
])

await dialog.getByRole("tab", { name: "Models" }).click()
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
Expand Down Expand Up @@ -150,6 +184,19 @@ type PermissionResponse = {
body: unknown
}

type MockPermission = {
id: string
sessionID: string
permission: string
patterns: string[]
metadata: Record<string, unknown>
always: string[]
}

function pendingPermission(id: string, sessionID: string): MockPermission {
return { id, sessionID, permission: "bash", patterns: ["git status"], metadata: {}, always: [] }
}

async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
await page.addInitScript(
({ serverB, tabs }) => {
Expand All @@ -161,7 +208,15 @@ async function configureServers(page: Page, tabs: { type: "session"; server: str
)
}

async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
async function mockServers(
page: Page,
permissionRequests: string[],
permissionResponses: PermissionResponse[] = [],
options: {
pending?: Record<string, MockPermission[]>
replyFailures?: Record<string, number>
} = {},
) {
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
Expand All @@ -171,6 +226,11 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
const requestDirectory = url.searchParams.get("directory")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
if (route.request().method() === "POST" && response) {
const failures = options.replyFailures?.[response[2]!] ?? 0
if (failures > 0) {
options.replyFailures![response[2]!] = failures - 1
return json(route, { name: "Internal" }, 500)
}
permissionResponses.push({
origin: url.origin,
directory: requestDirectory ?? undefined,
Expand Down Expand Up @@ -218,7 +278,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (url.pathname === "/permission") {
permissionRequests.push(url.toString())
return json(route, [])
return json(route, options.pending?.[url.origin] ?? [])
}
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
return json(route, [])
Expand Down
49 changes: 18 additions & 31 deletions packages/app/src/context/permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ function createServerPermissionState(input: {
}) {
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const AUTO_RESPONSE_RETRY_LIMIT = 2
const AUTO_RESPONSE_RETRY_DELAY_MS = 1000
const responded = new Map<string, number>()
const meta = { disposed: false }

Expand Down Expand Up @@ -223,19 +225,26 @@ function createServerPermissionState(input: {
.then((result) => result.data.map(normalizePermissionRequest))
}

function respondOnce(permission: PermissionRequest, directory?: string) {
function respondOnce(permission: PermissionRequest, directory?: string, attempt = 0) {
if (meta.disposed || !input.autoApprove()) return
const now = Date.now()
const hit = responded.has(permission.id)
responded.delete(permission.id)
responded.set(permission.id, now)
pruneResponded(now)
if (hit) return
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
input.sdk.api.permission
.reply({
sessionID: permission.sessionID,
requestID: permission.id,
reply: "once",
location: directory ? { directory } : undefined,
})
.catch(() => {
responded.delete(permission.id)
if (meta.disposed || !input.autoApprove() || attempt >= AUTO_RESPONSE_RETRY_LIMIT) return
setTimeout(() => respondOnce(permission, directory, attempt + 1), AUTO_RESPONSE_RETRY_DELAY_MS * (attempt + 1))
})
}

function sessions(directory?: string) {
Expand All @@ -261,26 +270,6 @@ function createServerPermissionState(input: {
return input.autoApprove()
}

function isPending(permission: PermissionRequest) {
const pending = input.sync.session.data.permission[permission.sessionID]
return pending === undefined || pending.some((item) => item.id === permission.id)
}

async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
return shouldAutoRespond(permission, directory)
}

async function respondPending(
permission: PermissionRequest,
directory?: string,
current: () => boolean = () => true,
) {
if (!current() || !isPending(permission)) return
if (!(await shouldAutoRespondResolved(permission, directory))) return
if (meta.disposed || !current() || !isPending(permission)) return
respondOnce(permission, directory)
}

const SWEEP_RETRY_LIMIT = 2
let sweepGeneration = 0

Expand Down Expand Up @@ -309,9 +298,7 @@ function createServerPermissionState(input: {
].map((directory) =>
list(directory).then(
(permissions) => {
permissions.forEach((permission) => {
void respondPending(permission, directory, input.autoApprove)
})
permissions.forEach((permission) => respondOnce(permission, directory))
return true
},
() => false,
Expand All @@ -334,7 +321,7 @@ function createServerPermissionState(input: {
return
}
if (event?.type !== "permission.asked") return
void respondPending(event.properties, e.name, input.autoApprove)
respondOnce(event.properties, e.name)
}

const unsubscribe = input.sdk.event.listen((event) => {
Expand Down
Loading