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
46 changes: 46 additions & 0 deletions docs/superpowers/plans/2026-09-19-opencode-permission-subject.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# OpenCode permission and question subjects: plan

**Issue:** #878 (release blocker; OpenCode is bundled, so this is the default path).
**Branch:** `fix/opencode-permission-subject` from `origin/main`.
**Package half:** Juliusolsson05/opencode-headless#14, MERGED (`62440add`). It adds one
shared subject parser, tested on recorded 1.18.30 streams, and bash subjects show the
whole command.

## Problem

The structured runtime's permission modal read "OpenCode is requesting permission."
with no subject, so users approved `bash: ls -1` blind. The question modal showed no
question. The package fix restores both. The app then has to render them safely,
because they are now populated for the first time. The package PR's review found two
problems that only this app can fix:
1. **A long subject overflows the modal.** A heredoc or a long `python3 -c` command
renders inline in a `<p>`, and `DialogContent` has no max height. The buttons go
off-screen, and the auto-focused "Allow once" answers Enter.
2. **"Allow always" looks scoped to what is shown, but is not.** `edit`, `write` and
MCP asks send `always: ["*"]`, so "Allow always" next to `edit: src/a.ts`
actually allows every edit for the session. OpenCode's own UI confirms the scope;
ours showed nothing.

## Change

- Bump `packages/opencode-headless` to `62440add`.
- `views.tsx`:
- render the subject in a scroll-contained `<pre>`
(`max-h-[40vh] overflow-auto whitespace-pre-wrap break-words`), untruncated,
because this modal is the only place the user sees the command;
- show what "Allow always" covers, from `metadata.always`, saying plainly when it
is `*`.
- Out of scope: answering questions. Options are in
`metadata.questions[].options`, but the only action is Reject; that is a follow-up
issue.

## Test (fail-first, recorded input)

`opencodePermissionView.renderer.test.tsx` replays the RECORDED 1.18.30 stream
(`packages/opencode-terminal-headless/testing/fixtures/live/permission-once.json`)
through the real `EventDispatcher` from the bumped package, and renders the real
view with the resulting state. It asserts:
- the full subject is shown, inside the scroll container;
- the recorded `always` scope (`ls *`) is shown next to "Allow always";
- a derived long heredoc command (only `metadata.command` changed) is rendered in
full inside the same container, never truncated.
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// opencodePermissionView renders what the user is actually approving (#878).
//
// WHY this replays a RECORDING through the real package instead of typing a
// state literal: the bug was a subject that never reached this modal, because
// a hand-imagined payload shape no longer matched the server. The input here
// is the real OpenCode 1.18.30 SSE bus, recorded by opencode-terminal-headless's
// Stage 0 probe (packages/opencode-terminal-headless/testing/fixtures/live).
// It goes through the REAL EventDispatcher from the bumped opencode-headless
// package, and the resulting screen state is what this view renders, exactly
// as opencodeSession.foldPermission passes it.
//
// Two risks become reachable once the subject exists at all
// (opencode-headless#14 review):
// - a long command, such as a heredoc or a `python3 -c` script, overflowed
// the fixed modal, pushing the buttons off-screen while the auto-focused
// "Allow once" answered Enter;
// - "Allow always" beside `edit: src/a.ts` silently meant EVERY edit
// (`always: ["*"]`), because the modal never showed the scope.

import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'

import { CommittedChannel, EventDispatcher, ScreenChannel, SemanticChannel } from 'opencode-headless'
import type { ScreenPermissionEvent } from 'opencode-headless'
import { opencodePermissionView, opencodeQuestionView } from './views'

type Recorded = { sessionID: string, sse: { event: { type: string, properties?: Record<string, unknown> } }[] }
const recording = (): Recorded => JSON.parse(readFileSync(resolve(__dirname, '../../../../../packages/opencode-headless/testing/fixtures/live-1.18.30/permission-once.json'), 'utf8'))

/** Replay recorded bus events through the real dispatcher and return the
* visible permission state the session would fold into the condition. */
function permissionStateFrom(rec: Recorded) {
const screenChannel = new ScreenChannel()
let latest: ScreenPermissionEvent['state'] | null = null
screenChannel.on('permission', (event: ScreenPermissionEvent) => { if (event.state.visible) latest = event.state })
const dispatcher = new EventDispatcher({ semantic: new SemanticChannel(), screen: screenChannel, committed: new CommittedChannel(), sessionID: rec.sessionID })
for (const { event } of rec.sse) dispatcher.dispatch(event)
if (!latest) throw new Error('recording produced no visible permission')
const state = latest as ScreenPermissionEvent['state']
// The same mapping opencodeSession.foldPermission applies.
return { visible: true as const, requestID: state.requestID!, title: state.title, metadata: state.metadata }
}

function mount(state: ReturnType<typeof permissionStateFrom>) {
const Component = opencodePermissionView.Component
render(<Component state={state} actions={[]} dispatch={async () => {}} interactionActive={false} />)
}

describe('opencode permission modal on a recorded 1.18.30 ask', () => {
it('shows the subject in a scroll-contained block, not inline prose', () => {
mount(permissionStateFrom(recording()))
const subject = screen.getByText('bash: ls -1')
// The contract is "the command can never push the buttons off-screen":
// it sits in a bounded, scrollable, wrapping block. Layout is not
// computable in happy-dom, so the containment is asserted on the element
// that owns it.
expect(subject.tagName).toBe('PRE')
expect(subject.className).toMatch(/max-h-/)
expect(subject.className).toMatch(/overflow-auto/)
})

it('says what "Allow always" covers, from the recorded always scope', () => {
mount(permissionStateFrom(recording()))
// The recorded ask carries `always: ["ls *"]`.
expect(screen.getByText(/Allow always covers/)).toBeTruthy()
expect(screen.getByText('ls *')).toBeTruthy()
})

it('renders a long command in full, never truncated (the modal is the only place the user sees it)', () => {
const rec = recording()
// DERIVED from the recording: only metadata.command changes, to a 60-line
// heredoc like the ones agents send.
const heredoc = `python3 - <<'EOF'\n${Array.from({ length: 60 }, (_, i) => `print(${i})`).join('\n')}\nEOF`
for (const { event } of rec.sse) {
if (event.type === 'permission.asked') event.properties = { ...event.properties, metadata: { command: heredoc } }
}
mount(permissionStateFrom(rec))
const subject = screen.getByText((_, element) => element?.tagName === 'PRE' && element.textContent === `bash: ${heredoc}`)
expect(subject.className).toMatch(/max-h-/)
})

it('shows the command behind a default-permission external_directory ask, not just the directory', () => {
// #1026 review: OpenCode's DEFAULT rules allow bash and ask only for
// external_directory, so for most users this is THE shell-command
// prompt. OpenCode's ShellTool.ask sends it as
// { permission: 'external_directory', patterns: [dir/*], metadata: { command } }.
// The subject reads "external_directory: /work/old/*". Without the
// command, Enter on "Allow once" runs `rm -rf /work/old` unseen.
// DERIVED from the recording: permission, patterns, metadata and always
// are reshaped to that ask; the id, session and tool linkage are real.
const rec = recording()
for (const { event } of rec.sse) {
if (event.type === 'permission.asked') {
event.properties = { ...event.properties, permission: 'external_directory', patterns: ['/work/old/*'], always: ['/work/old/*'], metadata: { command: 'rm -rf /work/old', directories: ['/work/old'] } }
}
}
mount(permissionStateFrom(rec))
expect(screen.getByText('rm -rf /work/old').tagName).toBe('PRE')
})

it('warns plainly when Allow always is a wildcard grant (edit, write, MCP asks send ["*"])', () => {
const rec = recording()
// DERIVED: the recorded ask reshaped to an MCP tool ask, which sends always: ['*'].
for (const { event } of rec.sse) {
if (event.type === 'permission.asked') event.properties = { ...event.properties, permission: 'github_create_issue', patterns: ['*'], always: ['*'], metadata: {} }
}
mount(permissionStateFrom(rec))
const warning = screen.getByText(/Allow always covers/)
// The grant covers the whole OpenCode server this agent runs, including
// its subagents, until it restarts. That is how OpenCode's own TUI words
// the same confirmation.
expect(warning.textContent).toMatch(/every github_create_issue request/)
expect(warning.textContent).toMatch(/until this agent restarts/)
})
})

describe('opencode question modal on a recorded 1.18.30 ask', () => {
it('keeps a long question scroll-contained so Reject stays on screen', () => {
type RecordedQuestion = Recorded
const rec: RecordedQuestion = JSON.parse(readFileSync(resolve(__dirname, '../../../../../packages/opencode-headless/testing/fixtures/live-1.18.30/question-reject.json'), 'utf8'))
const screenChannel = new ScreenChannel()
let latest: { visible: boolean, questionID?: string, text?: string } | null = null
screenChannel.on('question', (event: { state: { visible: boolean, questionID?: string, text?: string } }) => { if (event.state.visible) latest = event.state })
const dispatcher = new EventDispatcher({ semantic: new SemanticChannel(), screen: screenChannel, committed: new CommittedChannel(), sessionID: rec.sessionID })
for (const { event } of rec.sse) dispatcher.dispatch(event)
const state = latest as unknown as { visible: true, questionID: string, text: string }
const Component = opencodeQuestionView.Component
render(<Component state={state} actions={[]} dispatch={async () => {}} interactionActive={false} />)
const text = screen.getByText('Do you prefer the color red or blue?')
expect(text.className).toMatch(/max-h-/)
expect(text.className).toMatch(/overflow-auto/)
})
})
113 changes: 105 additions & 8 deletions src/providers/opencode/renderer/conditions/views.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,30 @@ function ConditionShell({
)
}

/** The raw permission.asked payload's own `metadata.command`, which is where
* OpenCode puts the shell command for both `bash` and bash-triggered
* `external_directory` asks. */
function askedCommand(metadata: unknown): string | undefined {
const inner = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>).metadata : undefined
const command = inner && typeof inner === 'object' ? (inner as Record<string, unknown>).command : undefined
return typeof command === 'string' && command.length > 0 ? command : undefined
}

/** The permission kind (`bash`, `edit`, an MCP tool key…) from the payload. */
function askedPermission(metadata: unknown): string | undefined {
const permission = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>).permission : undefined
return typeof permission === 'string' && permission.length > 0 ? permission : undefined
}

/** The patterns "Allow always" would grant, read from the raw
* permission.asked payload. Anything that is not a list of non-empty strings
* yields no scope line; the modal then says nothing about scope rather than
* guessing. */
function alwaysScope(metadata: unknown): string[] {
const always = metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>).always : undefined
return Array.isArray(always) ? always.filter((pattern): pattern is string => typeof pattern === 'string' && pattern.length > 0) : []
}

export const opencodePermissionView = defineView<
'opencode.permission',
OpencodePermissionState
Expand All @@ -130,15 +154,84 @@ export const opencodePermissionView = defineView<
actions={actions}
dispatch={dispatch}
>
<p className="mb-2">
OpenCode is requesting permission
{state.title ? (
{state.title ? (
<>
<p className="mb-1">OpenCode is requesting permission for:</p>
{/* WHY a bounded, scrolling <pre> (#878, opencode-headless#14
review): the subject is the command the user is approving,
shown in full because this modal is the ONLY place they see
it, so it must never be truncated. It can be a 100-line
heredoc, and DialogContent has no max height, so inline
prose pushed the buttons off-screen while the auto-focused
"Allow once" answered Enter. Wrapping keeps a long single-line
`python3 -c` legible; the height cap keeps the buttons on
screen. */}
<pre className="bg-code-bg rounded-slab text-code-ink px-3 py-2 mb-2 max-h-[40vh] overflow-auto whitespace-pre-wrap break-words text-[11.5px]">
{state.title}
</pre>
</>
) : (
<p className="mb-2">OpenCode is requesting permission.</p>
)}
{(() => {
// WHY the command gets its own block when the subject lacks it
// (#1026 review): OpenCode's DEFAULT rules allow `bash` and ask only
// for `external_directory`. For most users the shell-command prompt
// therefore reads "external_directory: /work/old/*", while the
// command that will actually run (`rm -rf /work/old`) sits only in
// the payload's metadata. The approval must show what executes, not
// just which folder it touches.
const command = askedCommand(state.metadata)
if (!command || (state.title ?? '').includes(command)) return null
return (
<>
{' '}for <span className="text-accent">{state.title}</span>
<p className="mb-1">Command:</p>
<pre className="bg-code-bg rounded-slab text-code-ink px-3 py-2 mb-2 max-h-[40vh] overflow-auto whitespace-pre-wrap break-words text-[11.5px]">
{command}
</pre>
</>
) : null}
.
</p>
)
})()}
{(() => {
// WHY the scope is spelled out: "Allow always" beside
// `edit: src/a.ts` looks scoped to that file, but OpenCode asks
// edit/write/MCP with `always: ["*"]`, meaning every such request for
// the rest of the session. OpenCode's own UI confirms the scope
// before granting it. Ours showed nothing, so the most permissive
// button read as the narrowest. The scope is the payload's own
// `always` list (state.metadata is the raw permission.asked
// payload).
const always = alwaysScope(state.metadata)
if (always.length === 0) return null
const permission = askedPermission(state.metadata)
// The reach is worded the way OpenCode's own TUI words it. The grant
// lives in the OpenCode server's memory: it covers every session on
// that server, which is this agent AND its subagents (task tool),
// and it ends when the server does. Agent Code runs one server per
// agent, so that means "until this agent restarts".
return (
<p className="mb-2 text-[11px] break-words">
{always.includes('*') ? (
<>
Allow always covers{' '}
<strong>every {permission ? <code className="text-accent">{permission}</code> : 'such'} request</strong>{' '}
from this agent and its subagents until this agent restarts.
</>
) : (
<>
Allow always covers {permission ? <>{permission}{' '}</> : null}
{always.map((pattern, index) => (
<span key={pattern}>
{index > 0 ? ', ' : ''}
<code className="text-accent">{pattern}</code>
</span>
))}{' '}
for this agent and its subagents until this agent restarts.
</>
)}
</p>
)
})()}
</ConditionShell>
)
},
Expand All @@ -156,7 +249,11 @@ export const opencodeQuestionView = defineView<
return (
<ConditionShell heading="OpenCode is asking" actions={actions} dispatch={dispatch}>
{state.text ? (
<pre className="bg-code-bg rounded-slab text-code-ink px-3 py-2 mb-1 overflow-x-auto whitespace-pre-wrap text-[11.5px]">
// Bounded and scrollable for the same reason as the permission
// subject: the text is now populated (#878), the modal has no max
// height, and Escape and outside-click are disabled, so a long
// question must never push the only button (Reject) off-screen.
<pre className="bg-code-bg rounded-slab text-code-ink px-3 py-2 mb-1 max-h-[40vh] overflow-auto whitespace-pre-wrap break-words text-[11.5px]">
{state.text}
</pre>
) : (
Expand Down
Loading