diff --git a/docs/superpowers/plans/2026-09-19-opencode-permission-subject.md b/docs/superpowers/plans/2026-09-19-opencode-permission-subject.md new file mode 100644 index 000000000..8e563ffd6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-opencode-permission-subject.md @@ -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 `
`, 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 `
`
+ (`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.
diff --git a/packages/opencode-headless b/packages/opencode-headless
index 4f2ef5de7..62440add8 160000
--- a/packages/opencode-headless
+++ b/packages/opencode-headless
@@ -1 +1 @@
-Subproject commit 4f2ef5de7c80ad7a6199dc09869ea3b728752f0e
+Subproject commit 62440add8787b17bddf9432dc80dd21de714f9d2
diff --git a/src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx b/src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx
new file mode 100644
index 000000000..4079fea5f
--- /dev/null
+++ b/src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx
@@ -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 } }[] }
+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) {
+ const Component = opencodePermissionView.Component
+ render( {}} 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( {}} 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/)
+ })
+})
diff --git a/src/providers/opencode/renderer/conditions/views.tsx b/src/providers/opencode/renderer/conditions/views.tsx
index d63fbed87..84e19aeeb 100644
--- a/src/providers/opencode/renderer/conditions/views.tsx
+++ b/src/providers/opencode/renderer/conditions/views.tsx
@@ -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).metadata : undefined
+ const command = inner && typeof inner === 'object' ? (inner as Record).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).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).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
@@ -130,15 +154,84 @@ export const opencodePermissionView = defineView<
actions={actions}
dispatch={dispatch}
>
-
- OpenCode is requesting permission
- {state.title ? (
+ {state.title ? (
+ <>
+
OpenCode is requesting permission for:
+ {/* WHY a bounded, scrolling (#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. */}
+
+ {state.title}
+
+ >
+ ) : (
+ OpenCode is requesting permission.
+ )}
+ {(() => {
+ // 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 {state.title}
+ Command:
+
+ {command}
+
>
- ) : null}
- .
-
+ )
+ })()}
+ {(() => {
+ // 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 (
+
+ {always.includes('*') ? (
+ <>
+ Allow always covers{' '}
+ every {permission ? {permission} : 'such'} request{' '}
+ from this agent and its subagents until this agent restarts.
+ >
+ ) : (
+ <>
+ Allow always covers {permission ? <>{permission}{' '}> : null}
+ {always.map((pattern, index) => (
+
+ {index > 0 ? ', ' : ''}
+ {pattern}
+
+ ))}{' '}
+ for this agent and its subagents until this agent restarts.
+ >
+ )}
+
+ )
+ })()}
)
},
@@ -156,7 +249,11 @@ export const opencodeQuestionView = defineView<
return (
{state.text ? (
-
+ // 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.
+
{state.text}
) : (