From d8159ff993a0778322ec169f0ee1218b2b776197 Mon Sep 17 00:00:00 2001 From: engineer Date: Mon, 27 Jul 2026 06:24:50 -0700 Subject: [PATCH 1/2] fix(opencode): purge pending permissions when a session is deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pending permission requests were held in an in-memory map that nothing cleared on session delete. The records outlived their session: GET /permission listed them forever, and the session-scoped reply route 404s ("Session not found") because it validates the session first — so they could never be resolved. Worse, the request's Deferred never settled, so the asking fiber hung: a deleted subagent froze its parent session. Permission now listens for session.deleted and rejects every pending request for that session, publishing permission.replied so clients drop the prompt. Child sessions publish their own delete event, so the recursive removal in Session.remove is covered without extra recursion. list() additionally sweeps orphans whose session row vanished without an observable event. Only requests whose session actually existed at ask() time are eligible, so requests made against synthetic session IDs are never swept. Requires Database in the permission layer for the existence checks. --- packages/opencode/src/permission/index.ts | 100 ++++++-- .../test/permission/session-delete.test.ts | 229 ++++++++++++++++++ 2 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 packages/opencode/test/permission/session-delete.test.ts diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index cd1f935adafa..bd012e7cb83d 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,6 +7,10 @@ import os from "os" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" +import { Database } from "@opencode-ai/core/database/database" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { eq, inArray } from "drizzle-orm" export const Event = { Asked: EventV2.define({ type: "permission.asked", schema: PermissionV1.Request.fields }), @@ -29,6 +33,10 @@ export interface Interface { interface PendingEntry { info: PermissionV1.Request deferred: Deferred.Deferred + // Whether the session row existed when the request was made. Only these are + // eligible for the orphan sweep — requests for sessions that never had a row + // (synthetic IDs) must survive it. + persisted: boolean } interface State { @@ -36,6 +44,25 @@ interface State { approved: PermissionV1.Rule[] } +// Drops every pending request matching `predicate`, rejecting its waiter so the +// caller's fiber cannot hang, and telling clients to dismiss the prompt. +const purge = Effect.fnUntraced(function* ( + state: State, + events: EventV2.Interface, + predicate: (entry: PendingEntry) => boolean, +) { + for (const [id, entry] of state.pending.entries()) { + if (!predicate(entry)) continue + state.pending.delete(id) + yield* events.publish(Event.Replied, { + sessionID: entry.info.sessionID, + requestID: entry.info.id, + reply: "reject", + }) + yield* Deferred.fail(entry.deferred, new PermissionV1.RejectedError()) + } +}) + export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule { return ( rulesets @@ -54,14 +81,26 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service + const { db } = yield* Database.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { - void ctx - const state = { + const state: State = { pending: new Map(), approved: [], } + // Session deletion must cascade to its pending permissions, otherwise + // they are listed forever and their waiters never resolve — which + // freezes the caller (a deleted subagent hangs its parent session). + // Child sessions publish their own event, so no recursion is needed. + const unsubscribe = yield* events.listen((event) => { + if (event.type !== SessionV1.Event.Deleted.type || event.location?.directory !== ctx.directory) + return Effect.void + const data = event.data as EventV2.Data + return purge(state, events, (entry) => entry.info.sessionID === data.sessionID) + }) + yield* Effect.addFinalizer(() => unsubscribe) + yield* Effect.addFinalizer(() => Effect.gen(function* () { for (const item of state.pending.values()) { @@ -107,7 +146,16 @@ export const layer = Layer.effect( yield* Effect.logInfo("asking", { id, permission: info.permission, patterns: info.patterns }) const deferred = yield* Deferred.make() - pending.set(id, { info, deferred }) + pending.set(id, { + info, + deferred, + persisted: !!(yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(eq(SessionTable.id, request.sessionID)) + .get() + .pipe(Effect.orDie)), + }) yield* events.publish(Event.Asked, info) return yield* Effect.ensuring( Deferred.await(deferred), @@ -118,7 +166,9 @@ export const layer = Layer.effect( }) const reply = Effect.fn("Permission.reply")(function* (input: PermissionV1.ReplyInput) { - const { approved, pending } = yield* InstanceState.get(state) + const current = yield* InstanceState.get(state) + const approved = current.approved + const pending = current.pending const existing = pending.get(input.requestID) if (!existing) return yield* new PermissionV1.NotFoundError({ requestID: input.requestID }) @@ -137,16 +187,7 @@ export const layer = Layer.effect( : new PermissionV1.RejectedError(), ) - for (const [id, item] of pending.entries()) { - if (item.info.sessionID !== existing.info.sessionID) continue - pending.delete(id) - yield* events.publish(Event.Replied, { - sessionID: item.info.sessionID, - requestID: item.info.id, - reply: "reject", - }) - yield* Deferred.fail(item.deferred, new PermissionV1.RejectedError()) - } + yield* purge(current, events, (item) => item.info.sessionID === existing.info.sessionID) return } @@ -177,9 +218,32 @@ export const layer = Layer.effect( } }) + // Sweeps orphans whose session vanished without an observable delete event + // (missed event, out-of-band row removal), so a zombie can never outlive + // its session in the listing. const list = Effect.fn("Permission.list")(function* () { - const pending = (yield* InstanceState.get(state)).pending - return Array.from(pending.values(), (item) => item.info) + const current = yield* InstanceState.get(state) + const sessions = [ + ...new Set( + Array.from(current.pending.values()) + .filter((item) => item.persisted) + .map((item) => item.info.sessionID), + ), + ] + if (sessions.length > 0) { + const alive = new Set( + ( + yield* db + .select({ id: SessionTable.id }) + .from(SessionTable) + .where(inArray(SessionTable.id, sessions)) + .all() + .pipe(Effect.orDie) + ).map((row) => row.id), + ) + yield* purge(current, events, (item) => item.persisted && !alive.has(item.info.sessionID)) + } + return Array.from(current.pending.values(), (item) => item.info) }) return Service.of({ ask, reply, list }) @@ -223,8 +287,8 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.ask({ + id, + sessionID, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }) + }).pipe(Effect.forkScoped) + +const list = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.list() + }) + +const waitForPending = (count: number) => + pollWithTimeout( + Effect.map(list(), (items) => (items.length === count ? items : undefined)), + `timed out waiting for ${count} pending permission request(s)`, + ) + +it.instance( + "session delete - removes pending permission requests from list", + () => + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const fiber = yield* askForever(info.id, PermissionV1.ID.make("per_delete_list")) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* session.remove(info.id) + yield* waitForPending(0) + + yield* Fiber.await(fiber) + }), + { git: true }, + { timeout: 30000 }, +) + +it.instance( + "session delete - terminates the pending ask fiber with RejectedError", + () => + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const fiber = yield* askForever(info.id, PermissionV1.ID.make("per_delete_fiber")) + + yield* waitForPending(1) + yield* session.remove(info.id) + + const exit = yield* Fiber.await(fiber).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("pending ask fiber never terminated after session delete")), + }), + ) + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) return + expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionV1.RejectedError) + }), + { git: true }, + { timeout: 30000 }, +) + +it.instance( + "session delete - publishes replied reject for the orphaned request", + () => + Effect.gen(function* () { + const bridge = yield* EventV2Bridge.Service + const session = yield* Session.Service + const info = yield* session.create({}) + const fiber = yield* askForever(info.id, PermissionV1.ID.make("per_delete_event")) + + yield* waitForPending(1) + + const seen = yield* Deferred.make<{ + sessionID: SessionID + requestID: PermissionV1.ID + reply: PermissionV1.Reply + }>() + const unsub = yield* bridge.listen((event) => { + if (event.type === Permission.Event.Replied.type) + Deferred.doneUnsafe( + seen, + Effect.succeed(event.data as { sessionID: SessionID; requestID: PermissionV1.ID; reply: PermissionV1.Reply }), + ) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsub) + + yield* session.remove(info.id) + + expect( + yield* Deferred.await(seen).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("timed out waiting for permission replied event")), + }), + ), + ).toEqual({ + sessionID: info.id, + requestID: PermissionV1.ID.make("per_delete_event"), + reply: "reject", + }) + + yield* Fiber.await(fiber) + }), + { git: true }, + { timeout: 30000 }, +) + +it.instance( + "session delete - purges pending permissions on child sessions", + () => + Effect.gen(function* () { + const session = yield* Session.Service + const parent = yield* session.create({}) + const child = yield* session.create({ parentID: parent.id }) + const fiber = yield* askForever(child.id, PermissionV1.ID.make("per_delete_child")) + + expect(yield* waitForPending(1)).toMatchObject([{ sessionID: child.id }]) + yield* session.remove(parent.id) + yield* waitForPending(0) + + const exit = yield* Fiber.await(fiber).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("child pending ask fiber never terminated after parent delete")), + }), + ) + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) return + expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionV1.RejectedError) + }), + { git: true }, + { timeout: 30000 }, +) + +it.instance( + "session delete - gc sweep drops orphans deleted out of band", + () => + Effect.gen(function* () { + const session = yield* Session.Service + const info = yield* session.create({}) + const fiber = yield* askForever(info.id, PermissionV1.ID.make("per_delete_gc")) + + yield* waitForPending(1) + + // Bypass Session.remove entirely so no session.deleted event is published: + // only the gc sweep inside list() can notice this orphan. + yield* (yield* Database.Service).db.delete(SessionTable).where(eq(SessionTable.id, info.id)).run().pipe(Effect.orDie) + + yield* waitForPending(0) + + const exit = yield* Fiber.await(fiber).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error("orphaned ask fiber never terminated after gc sweep")), + }), + ) + expect(Exit.isFailure(exit)).toBe(true) + if (!Exit.isFailure(exit)) return + expect(Cause.squash(exit.cause)).toBeInstanceOf(PermissionV1.RejectedError) + }), + { git: true }, + { timeout: 30000 }, +) + +it.instance( + "session delete - gc sweep leaves requests for never-persisted sessions alone", + () => + Effect.gen(function* () { + const permission = yield* Permission.Service + const fiber = yield* askForever(SessionID.make("session_synthetic"), PermissionV1.ID.make("per_delete_synth")) + + yield* waitForPending(1) + // The sweep runs on every list() call; a synthetic session that never had a + // row must survive repeated sweeps, otherwise every synthetic-ID test breaks. + for (let i = 0; i < 5; i++) { + expect(yield* list()).toHaveLength(1) + } + + yield* permission.reply({ requestID: PermissionV1.ID.make("per_delete_synth"), reply: "reject" }) + yield* Fiber.await(fiber) + }), + { git: true }, + { timeout: 30000 }, +) From 5dda566222f86cb119bb5ef414567c1e3aaa0d62 Mon Sep 17 00:00:00 2001 From: engineer Date: Mon, 27 Jul 2026 07:39:14 -0700 Subject: [PATCH 2/2] ci(smoke-test): call install-local.ts, the copy:local script no longer exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Install binary step ran `bun run copy:local`, which was removed from packages/opencode/package.json — every PR to dev fails the smoke test with `error: Script not found "copy:local"`. Invoke script/install-local.ts directly rather than the `install:local` script, since the latter rebuilds and would discard the OPENCODE_CHANNEL=local build produced by the preceding step. --- .github/workflows/smoke-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index e009c73f4b71..06c2ab29e5a7 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -28,7 +28,7 @@ jobs: - name: Install binary run: | cd packages/opencode - bun run copy:local + bun run script/install-local.ts - name: Run smoke test id: smoke