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;