Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/test/services/notification-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
import {
getPermissionLabel,
getPermissionDetail,
getPermissionAlwaysScope,
getFormText,
getGoalOutcomeTitle,
getGoalStopReasonLabel,
Expand Down Expand Up @@ -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', () => {
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 (
<PermissionRequestDialog
permission={currentPermission}
pendingCount={pendingCount}
isFromDifferentSession={false}
isFromDifferentSession={currentIsFromDifferentSession}
sessionTitle={session?.title ?? undefined}
repoDirectory={currentDirectory}
onOpenSession={canOpenCurrentSession ? navigateToCurrent : undefined}
onRespond={replyToPermission}
open={showDialog}
onOpenChange={setShowDialog}
repoDirectory={null}
/>
)
}
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/components/session/PermissionRequestDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const permission: PermissionRequest = {
sessionID: 'session-1',
action: 'shell',
resources: ['rm -rf node_modules'],
save: ['rm *'],
metadata: {},
}

Expand All @@ -29,9 +30,99 @@ describe('PermissionRequestDialog', () => {
render(<PermissionRequestDialog permission={permission} pendingCount={1} onRespond={vi.fn()} />)

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(
<PermissionRequestDialog
permission={{ ...permission, resources: ['git status', 'git push origin main'] }}
pendingCount={1}
onRespond={vi.fn()}
/>,
)

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(
<PermissionRequestDialog
permission={{ ...permission, message: 'This command modifies tracked files.' }}
pendingCount={1}
onRespond={vi.fn()}
/>,
)

expect(screen.getByText('This command modifies tracked files.')).toBeInTheDocument()
})

it('describes a wildcard always scope', () => {
render(
<PermissionRequestDialog
permission={{ ...permission, save: ['*'] }}
pendingCount={1}
onRespond={vi.fn()}
/>,
)

expect(screen.getByText('This will always allow shell for this project.')).toBeInTheDocument()
})

it('lists the patterns an always decision approves', () => {
render(
<PermissionRequestDialog
permission={{ ...permission, save: ['printf *', 'echo *'] }}
pendingCount={1}
onRespond={vi.fn()}
/>,
)

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(
<PermissionRequestDialog
permission={{ ...permission, save: undefined }}
pendingCount={1}
onRespond={vi.fn()}
/>,
)

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(
<PermissionRequestDialog
permission={permission}
pendingCount={1}
onRespond={vi.fn()}
onOpenSession={onOpenSession}
/>,
)

await userEvent.click(screen.getByRole('button', { name: 'Open session' }))

expect(onOpenSession).toHaveBeenCalledTimes(1)
})

it('omits the open session control without a handler', () => {
render(<PermissionRequestDialog permission={permission} pendingCount={1} onRespond={vi.fn()} />)

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(<PermissionRequestDialog permission={permission} pendingCount={1} onRespond={onRespond} />)
Expand Down
100 changes: 72 additions & 28 deletions frontend/src/components/session/PermissionRequestDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -21,6 +25,7 @@ interface PermissionRequestDialogProps {
isFromDifferentSession?: boolean
sessionTitle?: string
repoDirectory?: string | null
onOpenSession?: () => void
onRespond: (
permissionID: string,
sessionID: string,
Expand All @@ -37,6 +42,7 @@ export function PermissionRequestDialog({
isFromDifferentSession,
sessionTitle,
repoDirectory,
onOpenSession,
onRespond,
open: parentOpen,
onOpenChange,
Expand Down Expand Up @@ -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)}...`

Expand All @@ -95,15 +102,24 @@ export function PermissionRequestDialog({
{typeLabel}
</span>
</div>


{permission.message && (
<p className="text-xs sm:text-sm break-words">{permission.message}</p>
)}

{details.primary && (
<div className="bg-muted/50 border rounded-md p-2 sm:p-3 max-h-32 overflow-y-auto overflow-x-hidden">
<pre className="text-xs sm:text-sm font-mono whitespace-pre-wrap break-all w-full">
{details.primary}
</pre>
<div className="space-y-1">
{details.label && (
<span className="block text-xs font-medium text-muted-foreground">{details.label}</span>
)}
<div className="bg-muted/50 border rounded-md p-2 sm:p-3 max-h-32 overflow-y-auto overflow-x-hidden">
<pre className="text-xs sm:text-sm font-mono whitespace-pre-wrap break-all w-full">
{details.primary}
</pre>
</div>
</div>
)}

{details.secondary && (
<div className="bg-muted/30 border rounded-md p-2 sm:p-3 max-h-24 overflow-y-auto overflow-x-hidden">
<pre className="text-xs font-mono whitespace-pre-wrap break-all w-full text-muted-foreground">
Expand All @@ -114,19 +130,32 @@ export function PermissionRequestDialog({

<div className="text-xs text-muted-foreground space-y-1">
{repoDirectory && (
<div className="truncate">
<div className="truncate" title={repoDirectory}>
Repo: <span className="font-medium">{repoDirectory.split('/').pop() ?? repoDirectory}</span>
</div>
)}
{isFromDifferentSession ? (
<div className="bg-warning/10 text-warning border border-warning/20 rounded-md px-2 py-1.5 truncate">
From another session: <span className="font-medium">{displaySessionName}</span>
</div>
) : (
<div className="truncate">
Session: <span className="font-medium">{displaySessionName}</span>
</div>
)}
<div className="flex items-center gap-2">
{isFromDifferentSession ? (
<div className="bg-warning/10 text-warning border border-warning/20 rounded-md px-2 py-1.5 truncate min-w-0 flex-1">
From another session: <span className="font-medium">{displaySessionName}</span>
</div>
) : (
<div className="truncate min-w-0 flex-1">
Session: <span className="font-medium">{displaySessionName}</span>
</div>
)}
{onOpenSession && (
<Button
type="button"
variant="link"
size="sm"
onClick={onOpenSession}
className="h-auto shrink-0 p-0 text-xs"
>
Open session
</Button>
)}
</div>
</div>
</div>

Expand All @@ -144,6 +173,19 @@ export function PermissionRequestDialog({
/>
</div>

{alwaysScope && (
<div className="rounded-md border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground space-y-1">
<p>{alwaysScope.summary}</p>
{alwaysScope.patterns.length > 0 && (
<ul className="list-disc pl-4 space-y-0.5 font-mono break-all">
{alwaysScope.patterns.map((pattern) => (
<li key={pattern}>{pattern}</li>
))}
</ul>
)}
</div>
)}

<DialogFooter className="flex-col-reverse gap-2 sm:flex-row sm:gap-2 mt-2">
<Button
variant="outline"
Expand All @@ -156,17 +198,19 @@ export function PermissionRequestDialog({
>
{loadingAction === 'reject' ? 'Denying...' : 'Deny'}
</Button>
<Button
variant="secondary"
onClick={() => handleResponse('always')}
disabled={isLoading}
className={cn(
"w-full sm:flex-1 text-sm h-9 sm:h-10",
loadingAction === 'always' && "opacity-70"
)}
>
{loadingAction === 'always' ? 'Allowing...' : 'Allow Always'}
</Button>
{alwaysScope && (
<Button
variant="secondary"
onClick={() => handleResponse('always')}
disabled={isLoading}
className={cn(
"w-full sm:flex-1 text-sm h-9 sm:h-10",
loadingAction === 'always' && "opacity-70"
)}
>
{loadingAction === 'always' ? 'Allowing...' : 'Allow Always'}
</Button>
)}
<Button
variant="default"
onClick={() => handleResponse('once')}
Expand Down
Loading