From 3082949fac284cabcb8016abb4ddf1255f6e63b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 27 Aug 2026 13:31:18 +0000 Subject: [PATCH] answer opencode's permission prompts, because nothing else can MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the spinner had a cause and it was none of the four things fixed before it. the serve was holding a permission request: {"action":"skill","resources":["calendar-and-contacts"],"source":{"type":"tool"}} opencode asks before loading a skill. in its own terminal a person answers. here it goes to a UI that has never heard of it, and the turn does not fail — it SITS THERE. no error, no log line, nothing in the transcript. three models were blamed for it, and a paid provider, and the skill itself. replying `always` by hand released it instantly: the skill loaded and the agent went straight to the curl the skill describes. so the whole chain was correct and one unanswered question was holding all of it. CONFIG IS NOT ENOUGH, and this is the part worth keeping. the serve's config carries `permission: {skill: {"*": "allow"}}`, the agent's own ruleset reports `skill * allow`, and it asked anyway. answering is the only approach that does not depend on guessing the shape of the next gate correctly — and there will be a next gate, because `external_directory` was the last one. not a widening: the claude harness runs `bypassPermissions` deliberately, because CLAUDE.md is explicit that an unsandboxed agent on the owner's own machine is the point. this is the same stance for the other harness. `always` rather than `once` so a repeated tool does not re-ask every turn. when opencode reaches members this becomes a real decision. it is owner-only today for larger and unrelated reasons, and it goes on the list with them. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/sidecar/opencode/serve-runner.ts | 58 +++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/servers/sidecar/opencode/serve-runner.ts b/src/servers/sidecar/opencode/serve-runner.ts index 05293087..ef26aaca 100644 --- a/src/servers/sidecar/opencode/serve-runner.ts +++ b/src/servers/sidecar/opencode/serve-runner.ts @@ -100,7 +100,7 @@ function ensureEventStream(config: ServeConfig): void { for (const line of lines) { if (!line.startsWith('data:')) continue; try { - handleServeEvent(JSON.parse(line.slice(5).trim())); + handleServeEvent(JSON.parse(line.slice(5).trim()), config); } catch { /* a frame we could not parse is not worth killing the stream over */ } @@ -114,7 +114,61 @@ function ensureEventStream(config: ServeConfig): void { })(); } -function handleServeEvent(raw: unknown): void { +/* + Answer OpenCode's permission requests, because nothing else can. + + OpenCode asks before loading a skill, reading outside the project, and whatever it decides to gate + next. In its own terminal a person answers. Here the request goes to a UI that has never heard of it, + and the turn does not fail — it SITS THERE. A spinner on `skill calendar-and-contacts`, no error, no + log line, three models blamed and an afternoon gone. + + Config was tried first and is not enough: `permission: {skill: {"*": "allow"}}` is in the serve's + config, the agent's own ruleset reports `skill * allow`, and it asked anyway. Answering is the only + thing that does not depend on guessing the shape of the next gate correctly. + + This is not a widening. The Claude harness runs `bypassPermissions` deliberately — CLAUDE.md is + explicit that on the owner's own machine an unsandboxed agent is the point — and this is the same + stance for the other harness. `always` rather than `once` so a repeated tool does not re-ask on every + turn. + + A member's session would need this to become a real decision. OpenCode is owner-only today for + unrelated and larger reasons (`api/chat/chat.ts`); when that lifts, this is on the list with them. +*/ +async function approvePermission(config: ServeConfig, sessionID: string, requestID: string): Promise { + try { + const res = await fetch(`${config.baseUrl}/api/session/${sessionID}/permission/${requestID}/reply`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ reply: 'always' }), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) console.error(`[opencode] permission reply → ${res.status}`); + } catch (err) { + // Losing this leaves the turn hanging, so it is worth a line even though nothing can be done here. + console.error('[opencode] could not answer a permission request', err); + } +} + +/** A permission request, whatever the event is called — it is the ids that matter. */ +function permissionRequest(raw: unknown): { sessionID: string; requestID: string } | null { + const p = (raw as { properties?: Record })?.properties ?? (raw as Record); + const type = String((raw as { type?: string })?.type ?? ''); + if (!type.includes('permission')) return null; + const info = ((p as { info?: Record }).info ?? p) as Record; + const sessionID = info.sessionID ?? info.sessionId; + const requestID = info.id ?? info.requestID; + if (typeof sessionID !== 'string' || typeof requestID !== 'string') return null; + return { sessionID, requestID }; +} + +function handleServeEvent(raw: unknown, config?: ServeConfig): void { + const ask = permissionRequest(raw); + if (ask && config) { + console.log(`[opencode] approving ${ask.requestID} on ${ask.sessionID}`); + void approvePermission(config, ask.sessionID, ask.requestID); + return; + } + const mapped = mapServeEvent(raw as never); if (!mapped?.sessionId) return;