diff --git a/backend/test/services/notification-format.test.ts b/backend/test/services/notification-format.test.ts
index c83e30f7e..e7c72a450 100644
--- a/backend/test/services/notification-format.test.ts
+++ b/backend/test/services/notification-format.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
getPermissionLabel,
getPermissionDetail,
+ getPermissionAlwaysScope,
getFormText,
getGoalOutcomeTitle,
getGoalStopReasonLabel,
@@ -94,6 +95,42 @@ describe('getPermissionDetail', () => {
it('returns empty primary when no detail is available', () => {
expect(getPermissionDetail({ action: 'shell' }).primary).toBe('')
})
+ it('labels shell resources as commands', () => {
+ expect(getPermissionDetail({ action: 'shell', resources: ['ls'] }).label).toBe('Command')
+ expect(getPermissionDetail({ action: 'shell', resources: ['ls', 'pwd'] }).label).toBe('Commands')
+ })
+ it('labels search patterns', () => {
+ expect(getPermissionDetail({ action: 'grep', resources: ['TODO'] }).label).toBe('Pattern')
+ })
+ it('labels paths for read actions', () => {
+ expect(getPermissionDetail({ action: 'read', resources: ['/a', '/b'] }).label).toBe('Paths')
+ })
+ it('labels edit and webfetch details', () => {
+ expect(getPermissionDetail({ action: 'edit', metadata: { filepath: 'a.ts' } }).label).toBe('File')
+ expect(getPermissionDetail({ action: 'webfetch', metadata: { url: 'https://example.com' } }).label).toBe('URL')
+ })
+ it('falls back to a generic resource label for unknown actions', () => {
+ expect(getPermissionDetail({ action: 'frobnicate', resources: ['a'] }).label).toBe('Resource')
+ })
+})
+
+describe('getPermissionAlwaysScope', () => {
+ it('returns null when no save patterns are present', () => {
+ expect(getPermissionAlwaysScope({ action: 'shell' })).toBeNull()
+ expect(getPermissionAlwaysScope({ action: 'shell', save: [] })).toBeNull()
+ })
+ it('describes a wildcard scope', () => {
+ expect(getPermissionAlwaysScope({ action: 'shell', save: ['*'] })).toEqual({
+ summary: 'This will always allow shell for this project.',
+ patterns: [],
+ })
+ })
+ it('lists the saved patterns', () => {
+ expect(getPermissionAlwaysScope({ action: 'shell', save: ['printf *', 'echo *'] })).toEqual({
+ summary: 'This will always allow the following patterns for this project.',
+ patterns: ['printf *', 'echo *'],
+ })
+ })
})
describe('getFormText', () => {
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 3cd9d97bf..ba389943a 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -36,6 +36,7 @@ import { useAuth } from '@/hooks/useAuth'
import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast'
import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions'
import { useGlobalShortcutActions } from '@/hooks/useGlobalShortcutActions'
+import { useSession } from '@/hooks/useOpenCode'
import { RestartServerDialog } from '@/components/settings/RestartServerDialog'
const queryClient = new QueryClient({
@@ -103,17 +104,29 @@ function PermissionDialogWrapper() {
respond: replyToPermission,
showDialog,
setShowDialog,
+ navigateToCurrent,
+ viewedSessionId,
+ currentDirectory,
+ currentIsFromDifferentSession,
} = usePermissions()
+ const { getRepoIdForSession } = useEventContext()
+ const { data: session } = useSession(currentPermission?.sessionID, currentDirectory ?? undefined)
+
+ const canOpenCurrentSession = currentPermission !== null
+ && getRepoIdForSession(currentPermission.sessionID) !== null
+ && (currentIsFromDifferentSession || viewedSessionId === null)
return (
)
}
diff --git a/frontend/src/components/session/PermissionRequestDialog.test.tsx b/frontend/src/components/session/PermissionRequestDialog.test.tsx
index 0fed0cff9..ac99e2b94 100644
--- a/frontend/src/components/session/PermissionRequestDialog.test.tsx
+++ b/frontend/src/components/session/PermissionRequestDialog.test.tsx
@@ -15,6 +15,7 @@ const permission: PermissionRequest = {
sessionID: 'session-1',
action: 'shell',
resources: ['rm -rf node_modules'],
+ save: ['rm *'],
metadata: {},
}
@@ -29,9 +30,99 @@ describe('PermissionRequestDialog', () => {
render()
expect(screen.getByText('Run Command')).toBeInTheDocument()
+ expect(screen.getByText('Command')).toBeInTheDocument()
expect(screen.getByText('rm -rf node_modules')).toBeInTheDocument()
})
+ it('labels multiple shell resources as commands', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Commands')).toBeInTheDocument()
+ expect(screen.getByText(/git status/)).toBeInTheDocument()
+ expect(screen.getByText(/git push origin main/)).toBeInTheDocument()
+ })
+
+ it('shows the request message when present', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('This command modifies tracked files.')).toBeInTheDocument()
+ })
+
+ it('describes a wildcard always scope', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('This will always allow shell for this project.')).toBeInTheDocument()
+ })
+
+ it('lists the patterns an always decision approves', () => {
+ render(
+ ,
+ )
+
+ expect(
+ screen.getByText('This will always allow the following patterns for this project.'),
+ ).toBeInTheDocument()
+ expect(screen.getByText('printf *')).toBeInTheDocument()
+ expect(screen.getByText('echo *')).toBeInTheDocument()
+ })
+
+ it('omits the always option when no save patterns are provided', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByRole('button', { name: 'Allow Always' })).not.toBeInTheDocument()
+ expect(screen.queryByText(/This will always allow/)).not.toBeInTheDocument()
+ })
+
+ it('renders an open session control that triggers the handler', async () => {
+ const onOpenSession = vi.fn()
+ render(
+ ,
+ )
+
+ await userEvent.click(screen.getByRole('button', { name: 'Open session' }))
+
+ expect(onOpenSession).toHaveBeenCalledTimes(1)
+ })
+
+ it('omits the open session control without a handler', () => {
+ render()
+
+ expect(screen.queryByRole('button', { name: 'Open session' })).not.toBeInTheDocument()
+ })
+
it('replies with the selected decision for the permission request', async () => {
const onRespond = vi.fn().mockResolvedValue(undefined)
render()
diff --git a/frontend/src/components/session/PermissionRequestDialog.tsx b/frontend/src/components/session/PermissionRequestDialog.tsx
index 9389ab402..43b2670d4 100644
--- a/frontend/src/components/session/PermissionRequestDialog.tsx
+++ b/frontend/src/components/session/PermissionRequestDialog.tsx
@@ -11,7 +11,11 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import type { PermissionResponse } from '@/api/types'
import type { PermissionRequest } from '@opencode-manager/shared/opencode'
-import { getPermissionLabel, getPermissionDetail } from '@opencode-manager/shared/notifications'
+import {
+ getPermissionLabel,
+ getPermissionDetail,
+ getPermissionAlwaysScope,
+} from '@opencode-manager/shared/notifications'
import { cn } from '@/lib/utils'
import { showToast } from '@/lib/toast'
@@ -21,6 +25,7 @@ interface PermissionRequestDialogProps {
isFromDifferentSession?: boolean
sessionTitle?: string
repoDirectory?: string | null
+ onOpenSession?: () => void
onRespond: (
permissionID: string,
sessionID: string,
@@ -37,6 +42,7 @@ export function PermissionRequestDialog({
isFromDifferentSession,
sessionTitle,
repoDirectory,
+ onOpenSession,
onRespond,
open: parentOpen,
onOpenChange,
@@ -69,6 +75,7 @@ export function PermissionRequestDialog({
const typeLabel = getPermissionLabel(permission.action)
const details = getPermissionDetail(permission)
+ const alwaysScope = getPermissionAlwaysScope(permission)
const hasMultiple = pendingCount > 1
const displaySessionName = sessionTitle || `Session ${permission.sessionID.slice(0, 8)}...`
@@ -95,15 +102,24 @@ export function PermissionRequestDialog({
{typeLabel}
-
+
+ {permission.message && (
+
{permission.message}
+ )}
+
{details.primary && (
-
-
- {details.primary}
-
+
+ {details.label && (
+
{details.label}
+ )}
+
+
+ {details.primary}
+
+
)}
-
+
{details.secondary && (
@@ -114,19 +130,32 @@ export function PermissionRequestDialog({
{repoDirectory && (
-
+
Repo: {repoDirectory.split('/').pop() ?? repoDirectory}
)}
- {isFromDifferentSession ? (
-
- From another session: {displaySessionName}
-
- ) : (
-
- Session: {displaySessionName}
-
- )}
+
+ {isFromDifferentSession ? (
+
+ From another session: {displaySessionName}
+
+ ) : (
+
+ Session: {displaySessionName}
+
+ )}
+ {onOpenSession && (
+
+ )}
+
@@ -144,6 +173,19 @@ export function PermissionRequestDialog({
/>
+ {alwaysScope && (
+
+
{alwaysScope.summary}
+ {alwaysScope.patterns.length > 0 && (
+
+ {alwaysScope.patterns.map((pattern) => (
+ - {pattern}
+ ))}
+
+ )}
+
+ )}
+
-
+ {alwaysScope && (
+
+ )}