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}
           
) : (