fix(app): retain permission state per server (#36873)
This commit is contained in:
@@ -1,30 +1,19 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
const directoryA = "C:/server-a"
|
const directoryA = "C:/server-a"
|
||||||
const directoryB = "/home/server-b"
|
const directoryB = "/home/server-b"
|
||||||
const sessionB = {
|
const sessionA = session("ses_server_a", directoryA, "Server A session")
|
||||||
id: "ses_server_b",
|
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
|
||||||
slug: "ses_server_b",
|
const sessionB = session("ses_server_b", directoryB, "Server B session")
|
||||||
projectID: "project-server-b",
|
|
||||||
directory: directoryB,
|
|
||||||
title: "Server B session",
|
|
||||||
version: "dev",
|
|
||||||
time: { created: 1, updated: 1 },
|
|
||||||
}
|
|
||||||
|
|
||||||
test("session settings use the remote server context", async ({ page }) => {
|
test("session settings use the remote server context", async ({ page }) => {
|
||||||
const permissionRequests: string[] = []
|
const permissionRequests: string[] = []
|
||||||
await mockServers(page, permissionRequests)
|
await mockServers(page, permissionRequests)
|
||||||
await page.addInitScript(
|
await configureServers(page)
|
||||||
({ serverB }) => {
|
|
||||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
|
||||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
|
||||||
},
|
|
||||||
{ serverB },
|
|
||||||
)
|
|
||||||
|
|
||||||
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
@@ -53,21 +42,152 @@ test("session settings use the remote server context", async ({ page }) => {
|
|||||||
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function mockServers(page: Page, permissionRequests: string[]) {
|
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||||
|
const permissionRequests: string[] = []
|
||||||
|
const permissionResponses: PermissionResponse[] = []
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||||
|
server: serverA,
|
||||||
|
retry: 20,
|
||||||
|
})
|
||||||
|
await mockServers(page, permissionRequests, permissionResponses)
|
||||||
|
await configureServers(page, [
|
||||||
|
{ type: "session", server: serverA, sessionId: sessionA.id },
|
||||||
|
{ type: "session", server: serverB, sessionId: sessionB.id },
|
||||||
|
])
|
||||||
|
|
||||||
|
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||||
|
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||||
|
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||||
|
await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,")
|
||||||
|
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
|
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
permissionRequests.some((request) => {
|
||||||
|
const url = new URL(request)
|
||||||
|
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
|
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
|
await transport.waitForConnection()
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory: directoryA,
|
||||||
|
payload: {
|
||||||
|
id: "event-permission-background-a",
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "permission-background-a",
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["git status"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => permissionResponses)
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permissionID: "permission-background-a",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory: directoryA,
|
||||||
|
payload: {
|
||||||
|
id: "event-permission-background-a-child",
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "permission-background-a-child",
|
||||||
|
sessionID: childSessionA.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["git diff"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => permissionResponses)
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permissionID: "permission-background-a",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: childSessionA.id,
|
||||||
|
permissionID: "permission-background-a-child",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
type PermissionResponse = {
|
||||||
|
origin: string
|
||||||
|
directory?: string
|
||||||
|
sessionID: string
|
||||||
|
permissionID: string
|
||||||
|
body: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ serverB, tabs }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
|
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
||||||
|
},
|
||||||
|
{ serverB, tabs },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||||
const remote = url.origin === serverB
|
const remote = url.origin === serverB
|
||||||
const directory = remote ? directoryB : directoryA
|
const directory = remote ? directoryB : directoryA
|
||||||
|
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
||||||
const requestDirectory = url.searchParams.get("directory")
|
const requestDirectory = url.searchParams.get("directory")
|
||||||
|
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
||||||
|
if (route.request().method() === "POST" && response) {
|
||||||
|
permissionResponses.push({
|
||||||
|
origin: url.origin,
|
||||||
|
directory: requestDirectory ?? undefined,
|
||||||
|
sessionID: response[1]!,
|
||||||
|
permissionID: response[2]!,
|
||||||
|
body: route.request().postDataJSON(),
|
||||||
|
})
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session/status") return json(route, {})
|
if (url.pathname === "/session/status") return json(route, {})
|
||||||
if (url.pathname === "/session") return json(route, remote ? [sessionB] : [])
|
if (url.pathname === "/session") return json(route, sessions)
|
||||||
if (url.pathname === `/session/${sessionB.id}` && remote) return json(route, sessionB)
|
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||||
|
if (current) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${sessionB.id}/message`) return json(route, [])
|
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (url.pathname === "/permission") {
|
if (url.pathname === "/permission") {
|
||||||
permissionRequests.push(url.toString())
|
permissionRequests.push(url.toString())
|
||||||
@@ -101,6 +221,18 @@ async function mockServers(page: Page, permissionRequests: string[]) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function session(id: string, directory: string, title: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: `project-${id}`,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function provider(id: string) {
|
function provider(id: string) {
|
||||||
const name = id === "server-b" ? "Server B" : "Server A"
|
const name = id === "server-b" ? "Server B" : "Server A"
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export async function installSseTransport<T>(
|
|||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
||||||
return originalFetch(input, init)
|
return originalFetch(request)
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
const record = {
|
const record = {
|
||||||
|
|||||||
+17
-27
@@ -153,8 +153,7 @@ function LegacyTargetSessionRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
// Notification/Models + the visual Layout) for that server.
|
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
@@ -207,7 +206,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<DraftServerScopedProviders directory={directory}>
|
<ModelsProvider directory={directory}>
|
||||||
<SDKProvider directory={directory}>
|
<SDKProvider directory={directory}>
|
||||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
@@ -215,7 +214,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
|||||||
</DraftProviders>
|
</DraftProviders>
|
||||||
</DirectoryDataProvider>
|
</DirectoryDataProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</DraftServerScopedProviders>
|
</ModelsProvider>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -309,24 +308,21 @@ function DesktopCommands() {
|
|||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
sessionID?: () => string | undefined
|
|
||||||
serverScoped?: JSX.Element
|
serverScoped?: JSX.Element
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<LayoutProvider>
|
||||||
<LayoutProvider>
|
{props.serverScoped}
|
||||||
{props.serverScoped}
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
</LayoutProvider>
|
||||||
</LayoutProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
)
|
)
|
||||||
@@ -342,14 +338,6 @@ function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
|
||||||
return (
|
|
||||||
<PermissionProvider directory={props.directory}>
|
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
function DraftProviders(props: ParentProps) {
|
function DraftProviders(props: ParentProps) {
|
||||||
@@ -559,13 +547,15 @@ export function AppInterface(props: {
|
|||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<NotificationProvider>
|
<PermissionProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
</NotificationProvider>
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
|
</PermissionProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
|||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -27,6 +27,8 @@ let params: { id?: string } = {}
|
|||||||
let search: { draftId?: string } = {}
|
let search: { draftId?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
let variant: string | undefined
|
let variant: string | undefined
|
||||||
|
let permissionServer = "server-a"
|
||||||
|
let createSessionGate: Promise<void> | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
const prompt = {
|
const prompt = {
|
||||||
@@ -56,6 +58,7 @@ const clientFor = (directory: string) => {
|
|||||||
return {
|
return {
|
||||||
session: {
|
session: {
|
||||||
create: async () => {
|
create: async () => {
|
||||||
|
await createSessionGate
|
||||||
createdSessions.push(directory)
|
createdSessions.push(directory)
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -122,13 +125,14 @@ beforeAll(async () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => ({
|
mock.module("@/context/permission", () => {
|
||||||
usePermission: () => ({
|
const state = (server: string) => ({
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
enabledAutoAccept.push({ sessionID, directory })
|
enabledAutoAccept.push({ server, sessionID, directory })
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
}))
|
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
|
||||||
|
})
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
useServer: () => ({ key: "server-key" }),
|
useServer: () => ({ key: "server-key" }),
|
||||||
@@ -251,6 +255,8 @@ beforeEach(() => {
|
|||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
variant = undefined
|
||||||
|
permissionServer = "server-a"
|
||||||
|
createSessionGate = undefined
|
||||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -318,7 +324,40 @@ describe("prompt submit worktree selection", () => {
|
|||||||
|
|
||||||
await submit.handleSubmit(event)
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps auto-accept bound to the submission server", async () => {
|
||||||
|
let release = () => {}
|
||||||
|
createSessionGate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
prompt,
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => true,
|
||||||
|
mode: () => "shell",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
|
||||||
|
permissionServer = "server-b"
|
||||||
|
release()
|
||||||
|
await result
|
||||||
|
|
||||||
|
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("promotes drafts using the selected project's server", async () => {
|
test("promotes drafts using the selected project's server", async () => {
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
|
||||||
const projectDirectory = sdk().directory
|
const projectDirectory = sdk().directory
|
||||||
|
const permissionState = permission.currentServerState()
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
@@ -378,7 +379,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
session = created
|
session = created
|
||||||
await startTransition(() => {
|
await startTransition(() => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
|
||||||
local.session.promote(sessionDirectory, session.id, {
|
local.session.promote(sessionDirectory, session.id, {
|
||||||
agent: currentAgent.name,
|
agent: currentAgent.name,
|
||||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
const session = (input: { id: string; parentID?: string }) =>
|
||||||
({
|
({
|
||||||
@@ -69,6 +69,7 @@ describe("autoRespondsPermission", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||||
|
expect(sessionAutoAccept(autoAccept, sessions, permission("root"), directory)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session-level override takes precedence over directory-level", () => {
|
test("session-level override takes precedence over directory-level", () => {
|
||||||
@@ -81,6 +82,28 @@ describe("autoRespondsPermission", () => {
|
|||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("parent false override takes precedence over directory-level auto-accept", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: true,
|
||||||
|
[`${base64Encode(directory)}/root`]: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parent true override takes precedence over disabled directory fallback", () => {
|
||||||
|
const directory = "/tmp/project"
|
||||||
|
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||||
|
const autoAccept = {
|
||||||
|
[`${base64Encode(directory)}/*`]: false,
|
||||||
|
[`${base64Encode(directory)}/root`]: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("isDirectoryAutoAccepting", () => {
|
describe("isDirectoryAutoAccepting", () => {
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ export function directoryAcceptKey(directory: string) {
|
|||||||
|
|
||||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||||
const key = acceptKey(sessionID, directory)
|
const key = acceptKey(sessionID, directory)
|
||||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
return autoAccept[key] ?? autoAccept[sessionID]
|
||||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||||
@@ -44,8 +43,18 @@ export function autoRespondsPermission(
|
|||||||
permission: { sessionID: string },
|
permission: { sessionID: string },
|
||||||
directory?: string,
|
directory?: string,
|
||||||
) {
|
) {
|
||||||
const value = sessionLineage(session, permission.sessionID)
|
const value = sessionAutoAccept(autoAccept, session, permission, directory)
|
||||||
|
if (value !== undefined) return value
|
||||||
|
return directory ? isDirectoryAutoAccepting(autoAccept, directory) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionAutoAccept(
|
||||||
|
autoAccept: Record<string, boolean>,
|
||||||
|
session: { id: string; parentID?: string }[],
|
||||||
|
permission: { sessionID: string },
|
||||||
|
directory?: string,
|
||||||
|
) {
|
||||||
|
return sessionLineage(session, permission.sessionID)
|
||||||
.map((id) => accepted(autoAccept, id, directory))
|
.map((id) => accepted(autoAccept, id, directory))
|
||||||
.find((item): item is boolean => item !== undefined)
|
.find((item): item is boolean => item !== undefined)
|
||||||
return value ?? false
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import type { ServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { useGlobal } from "./global"
|
||||||
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
import { type DraftTab, useTabs } from "./tabs"
|
||||||
|
import { useSettings } from "./settings"
|
||||||
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import {
|
import {
|
||||||
acceptKey,
|
acceptKey,
|
||||||
directoryAcceptKey,
|
directoryAcceptKey,
|
||||||
isDirectoryAutoAccepting,
|
isDirectoryAutoAccepting,
|
||||||
autoRespondsPermission,
|
autoRespondsPermission,
|
||||||
|
sessionAutoAccept,
|
||||||
} from "./permission-auto-respond"
|
} from "./permission-auto-respond"
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
type PermissionRespondFn = (input: {
|
||||||
@@ -47,234 +54,417 @@ function hasPermissionPromptRules(permission: unknown) {
|
|||||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||||
name: "Permission",
|
name: "Permission",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: (props: { directory?: Accessor<string | undefined> }) => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSync = useServerSync()
|
const global = useGlobal()
|
||||||
|
const server = useServer()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const settings = useSettings()
|
||||||
|
const owner = getOwner()
|
||||||
|
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
|
||||||
|
|
||||||
|
const activeDraft = createMemo(() => {
|
||||||
|
if (!search.draftId) return
|
||||||
|
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeServer = createMemo(() => {
|
||||||
|
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
|
||||||
|
return activeDraft()?.server ?? server.key
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensure = (key: ServerConnection.Key) => {
|
||||||
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
|
||||||
|
if (!conn) throw new Error(`Permission server not found: ${key}`)
|
||||||
|
const ctx = global.ensureServerCtx(conn)
|
||||||
|
const existing = states.get(ctx.sdk.scope)
|
||||||
|
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
|
||||||
|
return existing.state
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
existing.dispose()
|
||||||
|
states.delete(ctx.sdk.scope)
|
||||||
|
}
|
||||||
|
const root = createRoot(
|
||||||
|
(dispose) => ({
|
||||||
|
key,
|
||||||
|
dispose,
|
||||||
|
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
|
||||||
|
}),
|
||||||
|
owner ?? undefined,
|
||||||
|
)
|
||||||
|
states.set(ctx.sdk.scope, root)
|
||||||
|
return root.state
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const list = global.servers.list()
|
||||||
|
const keys = new Set(list.map(ServerConnection.key))
|
||||||
|
states.forEach((value, scope) => {
|
||||||
|
if (keys.has(value.key)) return
|
||||||
|
value.dispose()
|
||||||
|
states.delete(scope)
|
||||||
|
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
|
||||||
|
if (replacement) ensure(ServerConnection.key(replacement))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => states.forEach((value) => value.dispose()))
|
||||||
|
|
||||||
|
let lastSelected: PermissionState | undefined
|
||||||
|
const selected = () => {
|
||||||
|
const key = activeServer()
|
||||||
|
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
|
||||||
|
lastSelected = ensure(key)
|
||||||
|
}
|
||||||
|
if (lastSelected) return lastSelected
|
||||||
|
return ensure(server.key)
|
||||||
|
}
|
||||||
|
const activeDirectory = createMemo(() => {
|
||||||
|
const directory = decode64(params.dir)
|
||||||
|
if (directory) return directory
|
||||||
|
const draft = activeDraft()
|
||||||
|
if (draft) return draft.directory
|
||||||
|
if (!params.id) return
|
||||||
|
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
|
||||||
|
return selected().sync.session.lineage.peek(params.id)?.session.directory
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const directory = activeDirectory()
|
||||||
|
if (!directory) return
|
||||||
|
selected().enableConfiguredDirectory(directory)
|
||||||
|
})
|
||||||
|
|
||||||
const permissionsEnabled = createMemo(() => {
|
const permissionsEnabled = createMemo(() => {
|
||||||
const directory = props.directory?.() ?? decode64(params.dir)
|
const directory = activeDirectory()
|
||||||
if (!directory) return false
|
if (!directory) return false
|
||||||
const [store] = serverSync().child(directory)
|
return selected().permissionsEnabled(directory)
|
||||||
return hasPermissionPromptRules(store.config.permission)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [store, setStore, _, ready] = persisted(
|
|
||||||
{
|
|
||||||
...Persist.serverGlobal(serverSDK().scope, "permission", ["permission.v3"]),
|
|
||||||
migrate(value) {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
|
||||||
|
|
||||||
const data = value as Record<string, unknown>
|
|
||||||
if (data.autoAccept) return value
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
autoAccept:
|
|
||||||
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
|
||||||
? data.autoAcceptEdits
|
|
||||||
: {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createStore({
|
|
||||||
autoAccept: {} as Record<string, boolean>,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
|
||||||
createEffect(() => {
|
|
||||||
if (!ready()) return
|
|
||||||
const directory = props.directory?.() ?? decode64(params.dir)
|
|
||||||
if (!directory) return
|
|
||||||
const [childStore] = serverSync().child(directory)
|
|
||||||
const perm = childStore.config.permission
|
|
||||||
if (typeof perm === "string" && perm === "allow") {
|
|
||||||
const key = directoryAcceptKey(directory)
|
|
||||||
if (store.autoAccept[key] === undefined) {
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const MAX_RESPONDED = 1000
|
|
||||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
|
||||||
const responded = new Map<string, number>()
|
|
||||||
const enableVersion = new Map<string, number>()
|
|
||||||
|
|
||||||
function pruneResponded(now: number) {
|
|
||||||
for (const [id, ts] of responded) {
|
|
||||||
if (now - ts < RESPONDED_TTL_MS) break
|
|
||||||
responded.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of responded.keys()) {
|
|
||||||
if (responded.size <= MAX_RESPONDED) break
|
|
||||||
responded.delete(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const respond: PermissionRespondFn = (input) => {
|
|
||||||
serverSDK()
|
|
||||||
.client.permission.respond(input)
|
|
||||||
.catch(() => {
|
|
||||||
responded.delete(input.permissionID)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function respondOnce(permission: PermissionRequest, directory?: string) {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
|
||||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
|
||||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAutoAcceptingDirectory(directory: string) {
|
|
||||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
|
||||||
const session = directory ? serverSync().child(directory, { bootstrap: false })[0].session : []
|
|
||||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bumpEnableVersion(sessionID: string, directory?: string) {
|
|
||||||
const key = acceptKey(sessionID, directory)
|
|
||||||
const next = (enableVersion.get(key) ?? 0) + 1
|
|
||||||
enableVersion.set(key, next)
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsubscribe = serverSDK().event.listen((e) => {
|
|
||||||
const event = e.details
|
|
||||||
if (event?.type !== "permission.asked") return
|
|
||||||
|
|
||||||
const perm = event.properties
|
|
||||||
if (!shouldAutoRespond(perm, e.name)) return
|
|
||||||
|
|
||||||
respondOnce(perm, e.name)
|
|
||||||
})
|
|
||||||
onCleanup(unsubscribe)
|
|
||||||
|
|
||||||
function enableDirectory(directory: string) {
|
|
||||||
const key = directoryAcceptKey(directory)
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = true
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
serverSDK()
|
|
||||||
.client.permission.list({ directory })
|
|
||||||
.then((x) => {
|
|
||||||
if (!isAutoAcceptingDirectory(directory)) return
|
|
||||||
for (const perm of x.data ?? []) {
|
|
||||||
if (!perm?.id) continue
|
|
||||||
if (!shouldAutoRespond(perm, directory)) continue
|
|
||||||
respondOnce(perm, directory)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableDirectory(directory: string) {
|
|
||||||
const key = directoryAcceptKey(directory)
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = false
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function enable(sessionID: string, directory: string) {
|
|
||||||
const key = acceptKey(sessionID, directory)
|
|
||||||
const version = bumpEnableVersion(sessionID, directory)
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = true
|
|
||||||
delete draft.autoAccept[sessionID]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
serverSDK()
|
|
||||||
.client.permission.list({ directory })
|
|
||||||
.then((x) => {
|
|
||||||
if (enableVersion.get(key) !== version) return
|
|
||||||
if (!isAutoAccepting(sessionID, directory)) return
|
|
||||||
for (const perm of x.data ?? []) {
|
|
||||||
if (!perm?.id) continue
|
|
||||||
if (!shouldAutoRespond(perm, directory)) continue
|
|
||||||
respondOnce(perm, directory)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
function disable(sessionID: string, directory?: string) {
|
|
||||||
bumpEnableVersion(sessionID, directory)
|
|
||||||
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
draft.autoAccept[key] = false
|
|
||||||
if (!directory) return
|
|
||||||
delete draft.autoAccept[sessionID]
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready: () => selected().ready(),
|
||||||
respond,
|
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
|
||||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
currentServerState: () => selected().api,
|
||||||
return shouldAutoRespond(permission, directory)
|
respond(input: Parameters<PermissionRespondFn>[0]) {
|
||||||
|
selected().respond(input)
|
||||||
|
},
|
||||||
|
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||||
|
return selected().autoResponds(permission, directory)
|
||||||
|
},
|
||||||
|
isAutoAccepting(sessionID: string, directory?: string) {
|
||||||
|
return selected().isAutoAccepting(sessionID, directory)
|
||||||
|
},
|
||||||
|
isAutoAcceptingDirectory(directory: string) {
|
||||||
|
return selected().isAutoAcceptingDirectory(directory)
|
||||||
},
|
},
|
||||||
isAutoAccepting,
|
|
||||||
isAutoAcceptingDirectory,
|
|
||||||
toggleAutoAccept(sessionID: string, directory: string) {
|
toggleAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID, directory)) {
|
selected().toggleAutoAccept(sessionID, directory)
|
||||||
disable(sessionID, directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
enable(sessionID, directory)
|
|
||||||
},
|
},
|
||||||
toggleAutoAcceptDirectory(directory: string) {
|
toggleAutoAcceptDirectory(directory: string) {
|
||||||
if (isAutoAcceptingDirectory(directory)) {
|
selected().toggleAutoAcceptDirectory(directory)
|
||||||
disableDirectory(directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enableDirectory(directory)
|
|
||||||
},
|
},
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
if (isAutoAccepting(sessionID, directory)) return
|
selected().enableAutoAccept(sessionID, directory)
|
||||||
enable(sessionID, directory)
|
|
||||||
},
|
},
|
||||||
disableAutoAccept(sessionID: string, directory?: string) {
|
disableAutoAccept(sessionID: string, directory?: string) {
|
||||||
disable(sessionID, directory)
|
selected().disableAutoAccept(sessionID, directory)
|
||||||
},
|
},
|
||||||
permissionsEnabled,
|
permissionsEnabled,
|
||||||
isPermissionAllowAll(directory: string) {
|
isPermissionAllowAll(directory: string) {
|
||||||
const [childStore] = serverSync().child(directory)
|
return selected().isPermissionAllowAll(directory)
|
||||||
const perm = childStore.config.permission
|
|
||||||
return typeof perm === "string" && perm === "allow"
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
type PermissionState = ReturnType<typeof createServerPermissionState>
|
||||||
|
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||||
|
|
||||||
|
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||||
|
const [store, setStore, _, ready] = persisted(
|
||||||
|
{
|
||||||
|
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||||
|
migrate(value) {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||||
|
|
||||||
|
const data = value as Record<string, unknown>
|
||||||
|
if (data.autoAccept) return value
|
||||||
|
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
autoAccept:
|
||||||
|
typeof data.autoAcceptEdits === "object" && data.autoAcceptEdits && !Array.isArray(data.autoAcceptEdits)
|
||||||
|
? data.autoAcceptEdits
|
||||||
|
: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createStore({
|
||||||
|
autoAccept: {} as Record<string, boolean>,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
function enableConfiguredDirectory(directory: string) {
|
||||||
|
if (meta.disposed || !ready()) return
|
||||||
|
const [childStore] = input.sync.child(directory)
|
||||||
|
if (childStore.config.permission !== "allow") return
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
if (store.autoAccept[key] !== undefined) return
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RESPONDED = 1000
|
||||||
|
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||||
|
const responded = new Map<string, number>()
|
||||||
|
const enableVersion = new Map<string, number>()
|
||||||
|
const meta = { disposed: false }
|
||||||
|
|
||||||
|
function pruneResponded(now: number) {
|
||||||
|
for (const [id, ts] of responded) {
|
||||||
|
if (now - ts < RESPONDED_TTL_MS) break
|
||||||
|
responded.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of responded.keys()) {
|
||||||
|
if (responded.size <= MAX_RESPONDED) break
|
||||||
|
responded.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const respond: PermissionRespondFn = (request) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
input.sdk.client.permission.respond(request).catch(() => {
|
||||||
|
responded.delete(request.permissionID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessions(directory?: string) {
|
||||||
|
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
|
||||||
|
if (!directory) return info
|
||||||
|
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||||
|
return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoAcceptingDirectory(directory: string) {
|
||||||
|
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||||
|
return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||||
|
if (override !== undefined) return override
|
||||||
|
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||||
|
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||||
|
if (meta.disposed || !lineage) return false
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpEnableVersion(sessionID: string, directory?: string) {
|
||||||
|
const key = acceptKey(sessionID, directory)
|
||||||
|
const next = (enableVersion.get(key) ?? 0) + 1
|
||||||
|
enableVersion.set(key, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePermission = (e: PermissionEvent) => {
|
||||||
|
const event = e.details
|
||||||
|
if (event?.type !== "permission.asked") return
|
||||||
|
void respondPending(event.properties, e.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = input.sdk.event.listen((event) => {
|
||||||
|
if (ready()) {
|
||||||
|
handlePermission(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void ready.promise?.then(() => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
handlePermission(event)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
meta.disposed = true
|
||||||
|
unsubscribe()
|
||||||
|
})
|
||||||
|
|
||||||
|
function enableDirectory(directory: string) {
|
||||||
|
if (meta.disposed) return
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
input.sdk.client.permission
|
||||||
|
.list({ directory })
|
||||||
|
.then((x) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (!isAutoAcceptingDirectory(directory)) return
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
if (!perm?.id) continue
|
||||||
|
void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableDirectory(directory: string) {
|
||||||
|
if (meta.disposed) return
|
||||||
|
const key = directoryAcceptKey(directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = false
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function enable(sessionID: string, directory: string) {
|
||||||
|
if (meta.disposed) return
|
||||||
|
const key = acceptKey(sessionID, directory)
|
||||||
|
const version = bumpEnableVersion(sessionID, directory)
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = true
|
||||||
|
delete draft.autoAccept[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
input.sdk.client.permission
|
||||||
|
.list({ directory })
|
||||||
|
.then((x) => {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (enableVersion.get(key) !== version) return
|
||||||
|
if (!isAutoAccepting(sessionID, directory)) return
|
||||||
|
for (const perm of x.data ?? []) {
|
||||||
|
if (!perm?.id) continue
|
||||||
|
void respondPending(
|
||||||
|
perm,
|
||||||
|
directory,
|
||||||
|
() => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable(sessionID: string, directory?: string) {
|
||||||
|
if (meta.disposed) return
|
||||||
|
bumpEnableVersion(sessionID, directory)
|
||||||
|
const key = directory ? acceptKey(sessionID, directory) : sessionID
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.autoAccept[key] = false
|
||||||
|
if (!directory) return
|
||||||
|
delete draft.autoAccept[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
ready: () => !meta.disposed && ready(),
|
||||||
|
respond,
|
||||||
|
autoResponds(permission: PermissionRequest, directory?: string) {
|
||||||
|
if (meta.disposed) return false
|
||||||
|
return shouldAutoRespond(permission, directory)
|
||||||
|
},
|
||||||
|
isAutoAccepting(sessionID: string, directory?: string) {
|
||||||
|
if (meta.disposed) return false
|
||||||
|
return isAutoAccepting(sessionID, directory)
|
||||||
|
},
|
||||||
|
isAutoAcceptingDirectory(directory: string) {
|
||||||
|
if (meta.disposed) return false
|
||||||
|
return isAutoAcceptingDirectory(directory)
|
||||||
|
},
|
||||||
|
toggleAutoAccept(sessionID: string, directory: string) {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (isAutoAccepting(sessionID, directory)) {
|
||||||
|
disable(sessionID, directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enable(sessionID, directory)
|
||||||
|
},
|
||||||
|
toggleAutoAcceptDirectory(directory: string) {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (isAutoAcceptingDirectory(directory)) {
|
||||||
|
disableDirectory(directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableDirectory(directory)
|
||||||
|
},
|
||||||
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
|
if (meta.disposed) return
|
||||||
|
if (isAutoAccepting(sessionID, directory)) return
|
||||||
|
enable(sessionID, directory)
|
||||||
|
},
|
||||||
|
disableAutoAccept(sessionID: string, directory?: string) {
|
||||||
|
if (meta.disposed) return
|
||||||
|
disable(sessionID, directory)
|
||||||
|
},
|
||||||
|
isPermissionAllowAll(directory: string) {
|
||||||
|
if (meta.disposed) return false
|
||||||
|
const [childStore] = input.sync.child(directory)
|
||||||
|
return childStore.config.permission === "allow"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...api,
|
||||||
|
api,
|
||||||
|
sync: input.sync,
|
||||||
|
enableConfiguredDirectory,
|
||||||
|
permissionsEnabled(directory: string) {
|
||||||
|
if (meta.disposed) return false
|
||||||
|
const [childStore] = input.sync.child(directory)
|
||||||
|
return hasPermissionPromptRules(childStore.config.permission)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export function useSessionTabAvatarState(
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
|
const permissionState = createMemo(() => permission.ensureServerState(server()))
|
||||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||||
const sync = createMemo(() => {
|
const sync = createMemo(() => {
|
||||||
const conn = connection()
|
const conn = connection()
|
||||||
@@ -23,7 +24,7 @@ export function useSessionTabAvatarState(
|
|||||||
if (!serverSync) return false
|
if (!serverSync) return false
|
||||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||||
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
|
||||||
return !permission.autoResponds(item, directory())
|
return !permissionState().autoResponds(item, directory())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const hasQuestions = createMemo(() => {
|
const hasQuestions = createMemo(() => {
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
|
||||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
@@ -159,7 +158,7 @@ export function TargetSessionRouteContent() {
|
|||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
|
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
|
||||||
return (
|
return (
|
||||||
// Settings must keep the complete target-server context and remain registered
|
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||||
// when session content falls back to the route error boundary.
|
// when session content falls back to the route error boundary.
|
||||||
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
|
||||||
<TargetSessionSettingsCommand />
|
<TargetSessionSettingsCommand />
|
||||||
@@ -294,10 +293,10 @@ function TargetServerScopedProviders(
|
|||||||
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<>
|
||||||
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</PermissionProvider>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user