mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 19:09:49 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1300e59c1a |
@@ -0,0 +1,167 @@
|
|||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/ServerAutoApprove"
|
||||||
|
const projectID = "proj_server_auto_approve"
|
||||||
|
const sourceID = "ses_server_auto_approve_source"
|
||||||
|
const targetID = "ses_server_auto_approve_target"
|
||||||
|
const sourceTitle = "Configure server auto-approve"
|
||||||
|
const targetTitle = "Use server auto-approve"
|
||||||
|
|
||||||
|
type EventPayload = {
|
||||||
|
directory: string
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
test("auto-approves permission requests across sessions on the same server", async ({ page }) => {
|
||||||
|
const events: EventPayload[] = []
|
||||||
|
const replies: Array<{ path: string; body: unknown }> = []
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "server-auto-approve",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"claude-opus-4-6": {
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [session(sourceID, sourceTitle, 1700000000000), session(targetID, targetTitle, 1700000001000)],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
events: () => events.splice(0, 1),
|
||||||
|
eventRetry: 16,
|
||||||
|
})
|
||||||
|
await page.route("**/pty/shells*", (route) =>
|
||||||
|
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
|
||||||
|
)
|
||||||
|
await configurePage(page)
|
||||||
|
|
||||||
|
page.on("request", (request) => {
|
||||||
|
const url = new URL(request.url())
|
||||||
|
if (request.method() !== "POST" || !url.pathname.includes("/permissions/")) return
|
||||||
|
replies.push({ path: url.pathname, body: request.postDataJSON() })
|
||||||
|
const requestID = url.pathname.split("/").at(-1)!
|
||||||
|
events.push(permissionReplied(targetID, requestID))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(sessionHref(sourceID))
|
||||||
|
await expectSessionTitle(page, sourceTitle)
|
||||||
|
await page.keyboard.press("Control+Comma")
|
||||||
|
const settings = page.getByRole("dialog")
|
||||||
|
await expect(settings).toBeVisible()
|
||||||
|
const autoApprove = settings.getByRole("switch").first()
|
||||||
|
await expect(autoApprove).not.toBeChecked()
|
||||||
|
await autoApprove.focus()
|
||||||
|
await page.keyboard.press("Space")
|
||||||
|
await expect(autoApprove).toBeChecked()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(settings).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Home" }).click()
|
||||||
|
await expect(page).toHaveURL("/")
|
||||||
|
await page.getByText(targetTitle, { exact: true }).last().click()
|
||||||
|
await expect(page).toHaveURL(sessionHref(targetID))
|
||||||
|
await expectSessionTitle(page, targetTitle)
|
||||||
|
events.push(permissionAsked(targetID, "permission-auto", "git status"))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => replies)
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
path: `/session/${targetID}/permissions/permission-auto`,
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
await expect(page.locator('[data-component="dock-prompt"][data-kind="permission"]')).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Comma")
|
||||||
|
await expect(settings).toBeVisible()
|
||||||
|
await autoApprove.focus()
|
||||||
|
await page.keyboard.press("Space")
|
||||||
|
await expect(autoApprove).not.toBeChecked()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(settings).toHaveCount(0)
|
||||||
|
|
||||||
|
events.push(permissionAsked(targetID, "permission-manual", "git diff"))
|
||||||
|
const dock = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
||||||
|
await expect(dock).toBeVisible()
|
||||||
|
await expect(dock.getByText("git diff", { exact: true })).toBeVisible()
|
||||||
|
expect(replies).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
function session(id: string, title: string, created: number) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created, updated: created },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionAsked(sessionID: string, id: string, pattern: string): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: { id, sessionID, permission: "bash", patterns: [pattern], metadata: {}, always: [] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionReplied(sessionID: string, requestID: string): EventPayload {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: { type: "permission.replied", properties: { sessionID, requestID, reply: "once" } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionHref(sessionID: string) {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configurePage(page: Parameters<typeof mockOpenCodeServer>[0]) {
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionIDs }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:tabs",
|
||||||
|
JSON.stringify(
|
||||||
|
sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64: base64Encode(directory), sessionId })),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionIDs: [sourceID, targetID] },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -41,7 +41,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
@@ -204,7 +203,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const permission = usePermission()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const tabs = () => props.controls.session.tabs
|
const tabs = () => props.controls.session.tabs
|
||||||
@@ -1147,12 +1145,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
const variants = createMemo(() => ["default", ...props.controls.model.selection.variant.list()])
|
||||||
// Check provider variants directly: `variants` also includes the UI-only default option.
|
// Check provider variants directly: `variants` also includes the UI-only default option.
|
||||||
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
|
const showVariantControl = createMemo(() => props.controls.model.selection.variant.list().length > 0)
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const id = props.controls.session.id
|
|
||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk().directory)
|
|
||||||
return permission.isAutoAccepting(id, sdk().directory)
|
|
||||||
})
|
|
||||||
|
|
||||||
const { abort, handleSubmit } =
|
const { abort, handleSubmit } =
|
||||||
props.submission ??
|
props.submission ??
|
||||||
createPromptSubmit({
|
createPromptSubmit({
|
||||||
@@ -1160,7 +1152,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
info,
|
info,
|
||||||
imageAttachments,
|
imageAttachments,
|
||||||
commentCount,
|
commentCount,
|
||||||
autoAccept: () => accepting(),
|
|
||||||
mode: () => store.mode,
|
mode: () => store.mode,
|
||||||
working,
|
working,
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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 optimistic: Array<{
|
const optimistic: Array<{
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@@ -117,14 +116,6 @@ beforeAll(async () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@/context/permission", () => ({
|
|
||||||
usePermission: () => ({
|
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
|
||||||
enabledAutoAccept.push({ sessionID, directory })
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module("@/context/server", () => ({
|
mock.module("@/context/server", () => ({
|
||||||
useServer: () => ({ key: "server-key" }),
|
useServer: () => ({ key: "server-key" }),
|
||||||
}))
|
}))
|
||||||
@@ -235,7 +226,6 @@ beforeAll(async () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
enabledAutoAccept.length = 0
|
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
optimisticSeeded.length = 0
|
optimisticSeeded.length = 0
|
||||||
promoted.length = 0
|
promoted.length = 0
|
||||||
@@ -256,7 +246,6 @@ describe("prompt submit worktree selection", () => {
|
|||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "shell",
|
mode: () => "shell",
|
||||||
working: () => false,
|
working: () => false,
|
||||||
editor: () => undefined,
|
editor: () => undefined,
|
||||||
@@ -288,34 +277,6 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies auto-accept to newly created sessions", async () => {
|
|
||||||
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 event = { preventDefault: () => undefined } as unknown as Event
|
|
||||||
|
|
||||||
await submit.handleSubmit(event)
|
|
||||||
|
|
||||||
expect(enabledAutoAccept).toEqual([{ 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 () => {
|
||||||
search = { draftId: "draft-1" }
|
search = { draftId: "draft-1" }
|
||||||
const submit = createPromptSubmit({
|
const submit = createPromptSubmit({
|
||||||
@@ -323,7 +284,6 @@ describe("prompt submit worktree selection", () => {
|
|||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
mode: () => "normal",
|
||||||
working: () => false,
|
working: () => false,
|
||||||
editor: () => undefined,
|
editor: () => undefined,
|
||||||
@@ -352,7 +312,6 @@ describe("prompt submit worktree selection", () => {
|
|||||||
info: () => ({ id: "session-1" }),
|
info: () => ({ id: "session-1" }),
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
mode: () => "normal",
|
||||||
working: () => false,
|
working: () => false,
|
||||||
editor: () => undefined,
|
editor: () => undefined,
|
||||||
@@ -384,7 +343,6 @@ describe("prompt submit worktree selection", () => {
|
|||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
autoAccept: () => false,
|
|
||||||
mode: () => "normal",
|
mode: () => "normal",
|
||||||
working: () => false,
|
working: () => false,
|
||||||
editor: () => undefined,
|
editor: () => undefined,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useServerSync, type ServerSync } from "@/context/server-sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
|
||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||||
import { useSync, type DirectorySync } from "@/context/sync"
|
import { useSync, type DirectorySync } from "@/context/sync"
|
||||||
@@ -175,7 +174,6 @@ type PromptSubmitInput = {
|
|||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<{ id: string } | undefined>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
commentCount: Accessor<number>
|
commentCount: Accessor<number>
|
||||||
autoAccept: Accessor<boolean>
|
|
||||||
mode: Accessor<"normal" | "shell">
|
mode: Accessor<"normal" | "shell">
|
||||||
working: Accessor<boolean>
|
working: Accessor<boolean>
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
@@ -199,7 +197,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
|
||||||
const prompt = input.prompt
|
const prompt = input.prompt
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -314,7 +311,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
const projectDirectory = sdk().directory
|
const projectDirectory = sdk().directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
const shouldAutoAccept = isNewSession && input.autoAccept()
|
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
@@ -374,7 +370,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (created) {
|
if (created) {
|
||||||
seed(sessionDirectory, created)
|
seed(sessionDirectory, created)
|
||||||
session = created
|
session = created
|
||||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
|
||||||
local.session.promote(sessionDirectory, session.id)
|
local.session.promote(sessionDirectory, session.id)
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
const draftID = search.draftId
|
const draftID = search.draftId
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { onCleanup } from "solid-js"
|
import { onCleanup } from "solid-js"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -6,7 +5,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
|
|
||||||
export function useSettingsDialog() {
|
export function useSettingsDialog() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams<{ id?: string }>()
|
|
||||||
let run = 0
|
let run = 0
|
||||||
let dead = false
|
let dead = false
|
||||||
|
|
||||||
@@ -16,10 +14,9 @@ export function useSettingsDialog() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
const current = ++run
|
const current = ++run
|
||||||
const sessionID = params.id
|
|
||||||
void import("@/components/settings-v2").then((module) => {
|
void import("@/components/settings-v2").then((module) => {
|
||||||
if (dead || run !== current) return
|
if (dead || run !== current) return
|
||||||
void dialog.show(() => <module.DialogSettings sessionID={sessionID} />)
|
void dialog.show(() => <module.DialogSettings />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
@@ -26,7 +25,6 @@ import {
|
|||||||
terminalInput,
|
terminalInput,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { Link } from "./link"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
@@ -87,37 +85,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
const dir = createMemo(() => decode64(params.dir))
|
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return false
|
|
||||||
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
|
||||||
return permission.isAutoAccepting(params.id, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleAccept = (checked: boolean) => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value) return
|
|
||||||
|
|
||||||
if (!params.id) {
|
|
||||||
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
|
||||||
permission.toggleAutoAcceptDirectory(value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
permission.enableAutoAccept(params.id, value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
permission.disableAutoAccept(params.id, value)
|
|
||||||
}
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
@@ -273,7 +245,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||||
>
|
>
|
||||||
<div data-action="settings-auto-accept-permissions">
|
<div data-action="settings-auto-accept-permissions">
|
||||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
<Switch checked={permission.autoApprove()} onChange={permission.setAutoApprove} />
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import { SettingsModelsV2 } from "./models"
|
|||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
import { SettingsServersV2 } from "./servers"
|
import { SettingsServersV2 } from "./servers"
|
||||||
|
|
||||||
export const DialogSettings: Component<{
|
export const DialogSettings: Component = () => {
|
||||||
sessionID?: string
|
|
||||||
}> = (props) => {
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
@@ -64,7 +62,7 @@ export const DialogSettings: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</TabsV2.List>
|
</TabsV2.List>
|
||||||
<TabsV2.Content value="general" class="settings-v2-panel">
|
<TabsV2.Content value="general" class="settings-v2-panel">
|
||||||
<SettingsGeneralV2 sessionID={props.sessionID} />
|
<SettingsGeneralV2 />
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
|
||||||
<SettingsKeybinds v2 />
|
<SettingsKeybinds v2 />
|
||||||
|
|||||||
@@ -80,9 +80,7 @@ const playDemoSound = (id: string | undefined) => {
|
|||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsGeneralV2: Component<{
|
export const SettingsGeneralV2: Component = () => {
|
||||||
sessionID?: string
|
|
||||||
}> = (props) => {
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
@@ -95,27 +93,6 @@ export const SettingsGeneralV2: Component<{
|
|||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
|
|
||||||
const dir = createMemo(() => {
|
|
||||||
if (!props.sessionID) return undefined
|
|
||||||
return serverSync().session.lineage.peek(props.sessionID)?.session.directory
|
|
||||||
})
|
|
||||||
const accepting = createMemo(() => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value || !props.sessionID) return false
|
|
||||||
return permission.isAutoAccepting(props.sessionID, value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggleAccept = (checked: boolean) => {
|
|
||||||
const value = dir()
|
|
||||||
if (!value || !props.sessionID) return
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
permission.enableAutoAccept(props.sessionID, value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
permission.disableAutoAccept(props.sessionID, value)
|
|
||||||
}
|
|
||||||
const desktop = createMemo(() => platform.platform === "desktop")
|
const desktop = createMemo(() => platform.platform === "desktop")
|
||||||
|
|
||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
@@ -251,7 +228,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
description={language.t("toast.permissions.autoaccept.on.description")}
|
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||||
>
|
>
|
||||||
<div data-action="settings-auto-accept-permissions">
|
<div data-action="settings-auto-accept-permissions">
|
||||||
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
<Switch checked={permission.autoApprove()} onChange={permission.setAutoApprove} />
|
||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
|
||||||
|
|
||||||
const session = (input: { id: string; parentID?: string }) =>
|
|
||||||
({
|
|
||||||
id: input.id,
|
|
||||||
parentID: input.parentID,
|
|
||||||
}) as Session
|
|
||||||
|
|
||||||
const permission = (sessionID: string) =>
|
|
||||||
({
|
|
||||||
sessionID,
|
|
||||||
}) as Pick<PermissionRequest, "sessionID">
|
|
||||||
|
|
||||||
describe("autoRespondsPermission", () => {
|
|
||||||
test("uses a parent session's directory-scoped auto-accept", () => {
|
|
||||||
const directory = "/tmp/project"
|
|
||||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
|
||||||
const autoAccept = {
|
|
||||||
[`${base64Encode(directory)}/root`]: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("uses a parent session's legacy auto-accept key", () => {
|
|
||||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
|
||||||
|
|
||||||
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defaults to requiring approval when no lineage override exists", () => {
|
|
||||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
|
||||||
const autoAccept = {
|
|
||||||
other: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), "/tmp/project")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("inherits a parent session's false override", () => {
|
|
||||||
const directory = "/tmp/project"
|
|
||||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
|
||||||
const autoAccept = {
|
|
||||||
[`${base64Encode(directory)}/root`]: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prefers a child override over parent override", () => {
|
|
||||||
const directory = "/tmp/project"
|
|
||||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
|
||||||
const autoAccept = {
|
|
||||||
[`${base64Encode(directory)}/root`]: false,
|
|
||||||
[`${base64Encode(directory)}/child`]: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("falls back to directory-level auto-accept", () => {
|
|
||||||
const directory = "/tmp/project"
|
|
||||||
const sessions = [session({ id: "root" })]
|
|
||||||
const autoAccept = {
|
|
||||||
[`${base64Encode(directory)}/*`]: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("session-level override takes precedence over directory-level", () => {
|
|
||||||
const directory = "/tmp/project"
|
|
||||||
const sessions = [session({ id: "root" })]
|
|
||||||
const autoAccept = {
|
|
||||||
[`${base64Encode(directory)}/*`]: true,
|
|
||||||
[`${base64Encode(directory)}/root`]: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("isDirectoryAutoAccepting", () => {
|
|
||||||
test("returns true when directory key is set", () => {
|
|
||||||
const directory = "/tmp/project"
|
|
||||||
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
|
|
||||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns false when directory key is not set", () => {
|
|
||||||
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns false when directory key is explicitly false", () => {
|
|
||||||
const directory = "/tmp/project"
|
|
||||||
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
|
|
||||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
|
|
||||||
export function acceptKey(sessionID: string, directory?: string) {
|
|
||||||
if (!directory) return sessionID
|
|
||||||
return `${base64Encode(directory)}/${sessionID}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function directoryAcceptKey(directory: string) {
|
|
||||||
return `${base64Encode(directory)}/*`
|
|
||||||
}
|
|
||||||
|
|
||||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
|
||||||
const key = acceptKey(sessionID, directory)
|
|
||||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
|
||||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
|
||||||
const key = directoryAcceptKey(directory)
|
|
||||||
return autoAccept[key] ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
|
||||||
const parent = session.reduce((acc, item) => {
|
|
||||||
if (item.parentID) acc.set(item.id, item.parentID)
|
|
||||||
return acc
|
|
||||||
}, new Map<string, string>())
|
|
||||||
const seen = new Set([sessionID])
|
|
||||||
const ids = [sessionID]
|
|
||||||
|
|
||||||
for (const id of ids) {
|
|
||||||
const parentID = parent.get(id)
|
|
||||||
if (!parentID || seen.has(parentID)) continue
|
|
||||||
seen.add(parentID)
|
|
||||||
ids.push(parentID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
export function autoRespondsPermission(
|
|
||||||
autoAccept: Record<string, boolean>,
|
|
||||||
session: { id: string; parentID?: string }[],
|
|
||||||
permission: { sessionID: string },
|
|
||||||
directory?: string,
|
|
||||||
) {
|
|
||||||
const value = sessionLineage(session, permission.sessionID)
|
|
||||||
.map((id) => accepted(autoAccept, id, directory))
|
|
||||||
.find((item): item is boolean => item !== undefined)
|
|
||||||
return value ?? false
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from "bun:test"
|
||||||
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { respondPermissionOnce } from "./permission-response"
|
||||||
|
|
||||||
|
const permission = (id: string): PermissionRequest => ({
|
||||||
|
id,
|
||||||
|
sessionID: "session",
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["git status"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending: Array<Parameters<Parameters<typeof respondPermissionOnce>[0]["respond"]>[0]> = []
|
||||||
|
const respond = async (input: (typeof pending)[number]) => {
|
||||||
|
pending.push(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pending.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("respondPermissionOnce", () => {
|
||||||
|
test("deduplicates the same request across provider instances", () => {
|
||||||
|
const request = permission(`permission-dedupe-${Date.now()}`)
|
||||||
|
const input = { scope: ServerScope.local, permission: request, directory: "/repo", respond }
|
||||||
|
|
||||||
|
respondPermissionOnce(input)
|
||||||
|
respondPermissionOnce(input)
|
||||||
|
|
||||||
|
expect(pending).toEqual([{ sessionID: "session", permissionID: request.id, response: "once", directory: "/repo" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps requests on different servers independent", () => {
|
||||||
|
const request = permission(`permission-server-${Date.now()}`)
|
||||||
|
|
||||||
|
respondPermissionOnce({ scope: ServerScope.local, permission: request, respond })
|
||||||
|
respondPermissionOnce({
|
||||||
|
scope: ServerScope.fromServerKey(ServerConnection.Key.make("remote")),
|
||||||
|
permission: request,
|
||||||
|
respond,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(pending).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows a retry after a failed response", async () => {
|
||||||
|
const request = permission(`permission-retry-${Date.now()}`)
|
||||||
|
let attempts = 0
|
||||||
|
const fail = async () => {
|
||||||
|
attempts += 1
|
||||||
|
throw new Error("failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
respondPermissionOnce({ scope: ServerScope.local, permission: request, respond: fail })
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
respondPermissionOnce({ scope: ServerScope.local, permission: request, respond: fail })
|
||||||
|
|
||||||
|
expect(attempts).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
|
const MAX_RESPONDED = 1000
|
||||||
|
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||||
|
const responded = new Map<string, number>()
|
||||||
|
|
||||||
|
function key(scope: ServerScope, permissionID: string) {
|
||||||
|
return `${scope}\0${permissionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function respondPermissionOnce(input: {
|
||||||
|
scope: ServerScope
|
||||||
|
permission: PermissionRequest
|
||||||
|
directory?: string
|
||||||
|
respond: (input: {
|
||||||
|
sessionID: string
|
||||||
|
permissionID: string
|
||||||
|
response: "once"
|
||||||
|
directory?: string
|
||||||
|
}) => Promise<unknown>
|
||||||
|
}) {
|
||||||
|
const id = key(input.scope, input.permission.id)
|
||||||
|
const now = Date.now()
|
||||||
|
const hit = responded.has(id)
|
||||||
|
responded.delete(id)
|
||||||
|
responded.set(id, now)
|
||||||
|
prune(now)
|
||||||
|
if (hit) return
|
||||||
|
|
||||||
|
void input
|
||||||
|
.respond({
|
||||||
|
sessionID: input.permission.sessionID,
|
||||||
|
permissionID: input.permission.id,
|
||||||
|
response: "once",
|
||||||
|
directory: input.directory,
|
||||||
|
})
|
||||||
|
.catch(() => responded.delete(id))
|
||||||
|
}
|
||||||
@@ -1,25 +1,12 @@
|
|||||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
import { type Accessor, createMemo, onCleanup } from "solid-js"
|
||||||
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 { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "./server-sync"
|
import { useServerSync } from "./server-sync"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import {
|
import { useSettings } from "./settings"
|
||||||
acceptKey,
|
import { respondPermissionOnce } from "./permission-response"
|
||||||
directoryAcceptKey,
|
|
||||||
isDirectoryAutoAccepting,
|
|
||||||
autoRespondsPermission,
|
|
||||||
} from "./permission-auto-respond"
|
|
||||||
|
|
||||||
type PermissionRespondFn = (input: {
|
|
||||||
sessionID: string
|
|
||||||
permissionID: string
|
|
||||||
response: "once" | "always" | "reject"
|
|
||||||
directory?: string
|
|
||||||
}) => void
|
|
||||||
|
|
||||||
function isNonAllowRule(rule: unknown) {
|
function isNonAllowRule(rule: unknown) {
|
||||||
if (!rule) return false
|
if (!rule) return false
|
||||||
@@ -51,6 +38,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const settings = useSettings()
|
||||||
|
const autoApprove = () => settings.permissions.autoApprove(serverSDK().scope)
|
||||||
|
|
||||||
const permissionsEnabled = createMemo(() => {
|
const permissionsEnabled = createMemo(() => {
|
||||||
const directory = props.directory?.() ?? decode64(params.dir)
|
const directory = props.directory?.() ?? decode64(params.dir)
|
||||||
@@ -59,215 +48,39 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
|||||||
return hasPermissionPromptRules(store.config.permission)
|
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) {
|
function respondOnce(permission: PermissionRequest, directory?: string) {
|
||||||
const now = Date.now()
|
respondPermissionOnce({
|
||||||
const hit = responded.has(permission.id)
|
scope: serverSDK().scope,
|
||||||
responded.delete(permission.id)
|
permission,
|
||||||
responded.set(permission.id, now)
|
|
||||||
pruneResponded(now)
|
|
||||||
if (hit) return
|
|
||||||
respond({
|
|
||||||
sessionID: permission.sessionID,
|
|
||||||
permissionID: permission.id,
|
|
||||||
response: "once",
|
|
||||||
directory,
|
directory,
|
||||||
|
respond: (input) => serverSDK().client.permission.respond(input),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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 unsubscribe = serverSDK().event.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (event?.type !== "permission.asked") return
|
if (event?.type !== "permission.asked") return
|
||||||
|
if (!autoApprove()) return
|
||||||
const perm = event.properties
|
respondOnce(event.properties, e.name)
|
||||||
if (!shouldAutoRespond(perm, e.name)) return
|
|
||||||
|
|
||||||
respondOnce(perm, e.name)
|
|
||||||
})
|
})
|
||||||
onCleanup(unsubscribe)
|
onCleanup(unsubscribe)
|
||||||
|
|
||||||
function enableDirectory(directory: string) {
|
function setAutoApprove(value: boolean) {
|
||||||
const key = directoryAcceptKey(directory)
|
settings.permissions.setAutoApprove(serverSDK().scope, value)
|
||||||
setStore(
|
if (!value) return
|
||||||
produce((draft) => {
|
for (const requests of Object.values(serverSync().session.data.permission)) {
|
||||||
draft.autoAccept[key] = true
|
for (const permission of requests ?? []) {
|
||||||
}),
|
const directory = serverSync().session.get(permission.sessionID)?.directory
|
||||||
)
|
if (directory) respondOnce(permission, directory)
|
||||||
|
}
|
||||||
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,
|
autoApprove,
|
||||||
respond,
|
setAutoApprove,
|
||||||
autoResponds(permission: PermissionRequest, directory?: string) {
|
autoResponds(_permission: PermissionRequest, _directory?: string) {
|
||||||
return shouldAutoRespond(permission, directory)
|
return autoApprove()
|
||||||
},
|
|
||||||
isAutoAccepting,
|
|
||||||
isAutoAcceptingDirectory,
|
|
||||||
toggleAutoAccept(sessionID: string, directory: string) {
|
|
||||||
if (isAutoAccepting(sessionID, directory)) {
|
|
||||||
disable(sessionID, directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
enable(sessionID, directory)
|
|
||||||
},
|
|
||||||
toggleAutoAcceptDirectory(directory: string) {
|
|
||||||
if (isAutoAcceptingDirectory(directory)) {
|
|
||||||
disableDirectory(directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
enableDirectory(directory)
|
|
||||||
},
|
|
||||||
enableAutoAccept(sessionID: string, directory: string) {
|
|
||||||
if (isAutoAccepting(sessionID, directory)) return
|
|
||||||
enable(sessionID, directory)
|
|
||||||
},
|
|
||||||
disableAutoAccept(sessionID: string, directory?: string) {
|
|
||||||
disable(sessionID, directory)
|
|
||||||
},
|
},
|
||||||
permissionsEnabled,
|
permissionsEnabled,
|
||||||
isPermissionAllowAll(directory: string) {
|
isPermissionAllowAll(directory: string) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
|
|||||||
import { createEffect, createMemo } from "solid-js"
|
import { createEffect, createMemo } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
agent: boolean
|
agent: boolean
|
||||||
@@ -43,7 +44,7 @@ export interface Settings {
|
|||||||
}
|
}
|
||||||
keybinds: Record<string, string>
|
keybinds: Record<string, string>
|
||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: boolean
|
autoApprove: Record<string, boolean>
|
||||||
}
|
}
|
||||||
notifications: NotificationSettings
|
notifications: NotificationSettings
|
||||||
sounds: SoundSettings
|
sounds: SoundSettings
|
||||||
@@ -127,7 +128,7 @@ const defaultSettings: Settings = {
|
|||||||
},
|
},
|
||||||
keybinds: {},
|
keybinds: {},
|
||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: false,
|
autoApprove: {},
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
agent: true,
|
agent: true,
|
||||||
@@ -175,6 +176,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setStore("general", "followup", "steer")
|
setStore("general", "followup", "steer")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const current: unknown = store.permissions?.autoApprove
|
||||||
|
if (typeof current !== "boolean") return
|
||||||
|
setStore("permissions", "autoApprove", {})
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
get current() {
|
get current() {
|
||||||
@@ -295,9 +302,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
permissions: {
|
permissions: {
|
||||||
autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
|
autoApprove(scope: ServerScope) {
|
||||||
setAutoApprove(value: boolean) {
|
return store.permissions?.autoApprove?.[scope] ?? false
|
||||||
setStore("permissions", "autoApprove", value)
|
},
|
||||||
|
setAutoApprove(scope: ServerScope, value: boolean) {
|
||||||
|
setStore("permissions", "autoApprove", scope, value)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
|
|||||||
@@ -139,11 +139,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
const mcpCommand = withCategory(language.t("command.category.mcp"))
|
||||||
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
const permissionsCommand = withCategory(language.t("command.category.permissions"))
|
||||||
|
|
||||||
const isAutoAcceptActive = () => {
|
const isAutoAcceptActive = permission.autoApprove
|
||||||
const sessionID = params.id
|
|
||||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk().directory)
|
|
||||||
return permission.isAutoAcceptingDirectory(sdk().directory)
|
|
||||||
}
|
|
||||||
const write = async (value: string) => {
|
const write = async (value: string) => {
|
||||||
const body = typeof document === "undefined" ? undefined : document.body
|
const body = typeof document === "undefined" ? undefined : document.body
|
||||||
if (body) {
|
if (body) {
|
||||||
@@ -277,13 +273,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleAutoAccept = () => {
|
const toggleAutoAccept = () => {
|
||||||
const sessionID = params.id
|
const active = !permission.autoApprove()
|
||||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk().directory)
|
permission.setAutoApprove(active)
|
||||||
else permission.toggleAutoAcceptDirectory(sdk().directory)
|
|
||||||
|
|
||||||
const active = sessionID
|
|
||||||
? permission.isAutoAccepting(sessionID, sdk().directory)
|
|
||||||
: permission.isAutoAcceptingDirectory(sdk().directory)
|
|
||||||
showToast({
|
showToast({
|
||||||
title: active
|
title: active
|
||||||
? language.t("toast.permissions.autoaccept.on.title")
|
? language.t("toast.permissions.autoaccept.on.title")
|
||||||
|
|||||||
Reference in New Issue
Block a user