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 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 }, +)