From 27b646f5922725c9396ff061d6f62c6a1400a710 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 18 Sep 2026 23:45:38 -0700 Subject: [PATCH 1/5] docs(opencode): plan the permission and question subject fix Refs #878 Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-09-19-opencode-permission-subject.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-19-opencode-permission-subject.md 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.

From 1d44a229ea7171259f83948e53d55ed36b25001f Mon Sep 17 00:00:00 2001
From: Julius Olsson 
Date: Fri, 18 Sep 2026 23:45:38 -0700
Subject: [PATCH 2/5] build(opencode): bump opencode-headless for real
 permission and question subjects

Picks up Juliusolsson05/opencode-headless#14. One shared parser reads the
subject OpenCode actually sends. bash shows the whole command, and
questions read questions[].question. Tested on recorded 1.18.30 streams.

Refs #878

Co-Authored-By: Claude Opus 5 (1M context) 
---
 packages/opencode-headless | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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

From 7dbd665901b20f9d13d12d9113fdeb8eca9cb1d0 Mon Sep 17 00:00:00 2001
From: Julius Olsson 
Date: Fri, 18 Sep 2026 23:46:58 -0700
Subject: [PATCH 3/5] test(opencode): render the permission modal from a
 recorded 1.18.30 ask

The recorded stream is replayed through the bumped package's real
EventDispatcher and rendered by the real view. It fails first: the
subject arrives but sits inline, and nothing says what Allow always
covers.

Refs #878

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .../opencodePermissionView.renderer.test.tsx  | 83 +++++++++++++++++++
 1 file changed, 83 insertions(+)
 create mode 100644 src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx

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..f7d9122aa
--- /dev/null
+++ b/src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx
@@ -0,0 +1,83 @@
+// 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 } from './views'
+
+type Recorded = { sessionID: string, sse: { event: { type: string, properties?: Record } }[] }
+const recording = (): Recorded => JSON.parse(readFileSync(resolve(__dirname, '../../../../../packages/opencode-terminal-headless/testing/fixtures/live/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-/)
+  })
+})

From 34c87ebd6df3d4b30c2be7560e83ec431a44e951 Mon Sep 17 00:00:00 2001
From: Julius Olsson 
Date: Fri, 18 Sep 2026 23:46:58 -0700
Subject: [PATCH 4/5] fix(opencode): show the full permission subject and the
 real Allow-always scope

With subjects populated for the first time, the modal has to render them
safely. The command goes in a bounded, scrolling, wrapping block,
untruncated, so a heredoc can no longer push the buttons off-screen
while Allow once holds focus. The modal also says what Allow always
covers, from the payload's own always list, and states plainly when that
is '*', meaning every such request for the session.

Fixes #878

Co-Authored-By: Claude Opus 5 (1M context) 
---
 .../opencode/renderer/conditions/views.tsx    | 64 ++++++++++++++++---
 1 file changed, 55 insertions(+), 9 deletions(-)

diff --git a/src/providers/opencode/renderer/conditions/views.tsx b/src/providers/opencode/renderer/conditions/views.tsx
index d63fbed87..85c077af0 100644
--- a/src/providers/opencode/renderer/conditions/views.tsx
+++ b/src/providers/opencode/renderer/conditions/views.tsx
@@ -114,6 +114,15 @@ function ConditionShell({
   )
 }
 
+/** 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 +139,52 @@ export const opencodePermissionView = defineView<
         actions={actions}
         dispatch={dispatch}
       >
-        

- OpenCode is requesting permission - {state.title ? ( - <> - {' '}for {state.title} - - ) : null} - . -

+ {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 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 + return ( +

+ Allow always covers{' '} + {always.map((pattern, index) => ( + + {index > 0 ? ', ' : ''} + {pattern} + + ))} + {always.includes('*') ? ': every request of this kind for the rest of the session.' : '.'} +

+ ) + })()} ) }, From 48bce88a100e646b740af86386ddcd6fcfad6137 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 19 Sep 2026 00:11:18 -0700 Subject: [PATCH 5/5] fix(opencode): show the command behind directory asks and state the true Allow-always reach From the #1026 review. OpenCode's default rules allow bash and ask only for external_directory, so for most users the shell prompt read 'external_directory: /work/old/*' while the command that runs sat unseen in the payload's metadata. The modal now shows that command whenever the subject does not already contain it. The Allow-always line now names the permission and states its real reach: OpenCode keeps the grant in the server's memory, so it covers this agent and its subagents until the agent restarts, which is how OpenCode's own TUI words it. The wildcard case reads 'every request'. The scope line wraps long patterns, and the question body is bounded and scrollable, since the only button (Reject) must stay on screen now that questions render. Tests read the recording shipped in the bumped package. Refs #878 Co-Authored-By: Claude Opus 5 (1M context) --- .../opencodePermissionView.renderer.test.tsx | 56 ++++++++++++- .../opencode/renderer/conditions/views.tsx | 79 +++++++++++++++---- 2 files changed, 119 insertions(+), 16 deletions(-) diff --git a/src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx b/src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx index f7d9122aa..4079fea5f 100644 --- a/src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx +++ b/src/providers/opencode/renderer/conditions/opencodePermissionView.renderer.test.tsx @@ -24,10 +24,10 @@ import { describe, expect, it } from 'vitest' import { CommittedChannel, EventDispatcher, ScreenChannel, SemanticChannel } from 'opencode-headless' import type { ScreenPermissionEvent } from 'opencode-headless' -import { opencodePermissionView } from './views' +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-terminal-headless/testing/fixtures/live/permission-once.json'), 'utf8')) +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. */ @@ -80,4 +80,56 @@ describe('opencode permission modal on a recorded 1.18.30 ask', () => { 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 85c077af0..84e19aeeb 100644 --- a/src/providers/opencode/renderer/conditions/views.tsx +++ b/src/providers/opencode/renderer/conditions/views.tsx @@ -114,6 +114,21 @@ 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 @@ -151,16 +166,32 @@ export const opencodePermissionView = defineView< "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 ( + <> +

Command:

+
+                {command}
+              
+ + ) + })()} {(() => { // WHY the scope is spelled out: "Allow always" beside // `edit: src/a.ts` looks scoped to that file, but OpenCode asks @@ -172,16 +203,32 @@ export const opencodePermissionView = defineView< // 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 ( -

- Allow always covers{' '} - {always.map((pattern, index) => ( - - {index > 0 ? ', ' : ''} - {pattern} - - ))} - {always.includes('*') ? ': every request of this kind for the rest of the session.' : '.'} +

+ {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. + + )}

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