DashboardopencodeSession 0hDs7WYqFiTf

Session 0hDs7WYqFiTf

Full ID: 0hDs7WYqFiTftFFj1
Messages: 20
Distillations: 0
Time range: 2026-09-15 23:22:53 — 2026-09-15 23:30:09

Conversation (20 messages)

Independent adversarial READ-ONLY security/integrity review of revised frozen V2 recovery candidate. Do not use lore/recall.

Immutable sources only:

  • Base ab9408c81cc9916177641ae8e9f08624fc27b5b9
  • Patch /tmp/opencode/session-recovery-final-v2.patch, SHA-256 b5878a90af5202832954778d14d2902e55ee9e51ff856592519177bafd392d90
  • SEA /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server, SHA-256 9bbe18549e623e6ebe4ed6f75c5403381c35ae860ad2dc77698a469ab8a6c7e6
  • Exactly 32 patch files. Never use mutable worktree source. No edits/tests/build/commit/services/VCS mutation.

Review all files for cross-Session confusion, event forgery/replay, lease theft/revival, stale-owner mutation/dispatch, successor deletion, duplicate continuation, cancellation races, wake loss, resume:false leakage, poison/batch DoS, migration integrity, generated client drift, forbidden dependencies.

Prior blocker now fixed: ordinary hasPending and queued promotion anti-join every session_recovery.continuation_message_id, including quarantined rows; exact recovery promotion remains reserved. Verify this prevents stale/malformed continuation escape without creating deletion/orphan/liveness vulnerabilities.

Required invariants: no uncertain provider/tool replay; only fixed fresh queued continue; never live lease/older assistant recovery; exact unexpired Session/execution/owner fences durable boundaries; historical PromptAdmitted remains admit-only; retries cannot wake unrelated inputs; recovery identities deterministic/same-Session.

Accepted residuals to assess: instruction-level check-to-external-call race and bounded clock-skew assumption.

Evidence: quarantine regression deterministically fails on prior patch and passes now; Core 1,141/0, typechecks/schema/migration/diff pass; rebuilt SEA health/shutdown and exactly-once recovery across second restart pass.

Findings first, MUST-FIX/CONCERN, exact frozen resulting-file lines and exploit sequence. ...

gateway · 2026-09-15 23:22:53
result
b5878a90af5202832954778d14d2902e55ee9e51ff856592519177bafd392d90  /tmp/opencode/session-recovery-final-v2.patch
9bbe18549e623e6ebe4ed6f75c5403381c35ae860ad2dc77698a469ab8a6c7e6  /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server
commit
gateway · 2026-09-15 23:23:16
result
<path>/tmp/opencode/session-recovery-final-v2.patch</path>
<type>file</type>
<content>
1: diff --git a/packages/core/schema.json b/packages/core/schema.json
2: index d0eeeebd5c..dad07b6ce6 100644
3: --- a/packages/core/schema.json
4: +++ b/packages/core/schema.json
5: @@ -1,8 +1,8 @@
6:  {
7:    "version": "7",
8:    "dialect": "sqlite",
9: -  "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
10: -  "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
11: +  "id": "797e6838-8a41-4793-932a-d200aa8d51fb",
12: +  "prevIds": ["0445bfe8-162e-4719-a49b-23f1210dcacd"],
13:    "ddl": [
14:      {
15:        "name": "workspace",
16: @@ -60,6 +60,10 @@
17:        "name": "session_context_epoch",
18:        "entityType": "tables"
19:      },
20: +    {
21: +      "name": "session_execution",
22: +      "entityType": "tables"
23: +    },
24:      {
25:        "name": "session_input",
26:        "entityType": "tables"
27: @@ -68,6 +72,10 @@
28:        "name": "session_message",
29:        ...
gateway · 2026-09-15 23:23:22
result
<path>/tmp/opencode/session-recovery-final-v2.patch</path>
<type>file</type>
<content>
1387:    rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
1388: +  commit?: (row: typeof SessionInputTable.$inferSelect) => Effect.Effect<void>,
1389:  ) {
1390:    for (const row of rows) {
1391:      const id = SessionMessage.ID.make(row.id)
1392:      yield* events
1393: -      .publish(SessionEvent.Prompted, {
1394: -        sessionID,
1395: -        timestamp: DateTime.makeUnsafe(row.time_created),
1396: -        messageID: id,
1397: -        prompt: decodePrompt(row.prompt),
1398: -        delivery: row.delivery,
1399: -      })
1400: +      .publish(
1401: +        SessionEvent.Prompted,
1402: +        {
1403: +          sessionID,
1404: +          timestamp: DateTime.makeUnsafe(row.time_created),
1405: +          messageID: id,
1406: +          prompt: decodePrompt(row.prompt),
1407: +          delivery: row.delivery,
1408: +        },
1409: +        commit ? ...
gateway · 2026-09-15 23:23:37
result
<path>/tmp/opencode/session-recovery-final-v2.patch</path>
<type>file</type>
<content>
2537: diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts
2538: index 33652a618c..518a180e24 100644
2539: --- a/packages/core/src/session/runner/publish-llm-event.ts
2540: +++ b/packages/core/src/session/runner/publish-llm-event.ts
2541: @@ -11,6 +11,10 @@ type Input = {
2542:    readonly agent: string
2543:    readonly model: ModelV2.Ref
2544:    readonly snapshot?: string
2545: +  readonly startCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
2546: +  readonly terminalCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
2547: +  readonly interruptedCommit: (assistantMessageID: SessionMessage.ID) => Effect.Effect<void>
2548: +  readonly eventCommit: () => Effect.Effect<void>
2549:  }
2550:  
2551:  const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? ...
gateway · 2026-09-15 23:23:42
result
Found 32 matches
/tmp/opencode/session-recovery-final-v2.patch:
  Line 1: diff --git a/packages/core/schema.json b/packages/core/schema.json

  Line 410: diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts

  Line 421: diff --git a/packages/core/src/database/migration/20260914170650_session-recovery.ts b/packages/core/src/database/migration/20260914170650_session-recovery.ts

  Line 477: diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts

  Line 563: diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts

  Line 595: diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts

  Line 684: diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts

  Line 860: diff --git a/packages/core/src/session/execution/claim.ts b/packages/core/src/session/execution/claim.ts

  Line 932: diff --git ...
gateway · 2026-09-15 23:23:53
result
<path>/tmp/opencode/session-recovery-final-v2.patch</path>
<type>file</type>
<content>
3812: +        prompt: Prompt.make({ text: "recover ordinary input" }),
3813: +        delivery: "steer",
3814: +        requestExecution: true,
3815: +      })
3816: +      let wakes = 0
3817: +      const execution = SessionExecution.Service.of({
3818: +        ...recoveryExecution,
3819: +        wake: () => Effect.sync(() => wakes++).pipe(Effect.asVoid),
3820: +      })
3821: +
3822: +      yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
3823: +      yield* SessionRecovery.recover().pipe(Effect.provideService(SessionExecution.Service, execution))
3824: +
3825: +      expect(wakes).toBe(2)
3826: +      expect(yield* db.select().from(SessionInputTable).all()).toEqual([
3827: +        expect.objectContaining({ wake_pending: true, promoted_seq: null }),
3828: +      ])
3829: +    }),
3830: +  )
3831: +
3832: +  it.effect("rejects a recovered ...
gateway · 2026-09-15 23:23:57
result
<path>/tmp/opencode/session-recovery-final-v2.patch</path>
<type>file</type>
<content>
5188: +      const session = yield* SessionV2.Service
5189: +      const { db } = yield* Database.Service
5190: +      response = fragmentFixture("text", "text-before-compaction", ["Earlier answer"]).completeEvents
5191: +      yield* session.prompt({
5192: +        sessionID,
5193: +        prompt: Prompt.make({ text: "Earlier question ".repeat(180) }),
5194: +        resume: false,
5195: +      })
5196: +      yield* session.resume(sessionID)
5197: +
5198: +      currentModel = compactModel
5199: +      requests.length = 0
5200: +      responseStream = Stream.fromIterable(
5201: +        fragmentFixture("text", "text-uncommitted-summary", ["## Objective\n- Interrupted"]).completeEvents,
5202: +      ).pipe(
5203: +        Stream.ensuring(
5204: +          db
5205: +            .update(SessionExecutionTable)
5206: +            .set({ id: "compaction-successor", owner_id: "successor", expires_at: ...
gateway · 2026-09-15 23:24:02
result
<path>/tmp/opencode/session-recovery-final-v2.patch</path>
<type>file</type>
<content>
6712: +            "additionalProperties": false
6713: +          }
6714: +        },
6715: +        "required": ["id", "type", "data"],
6716: +        "additionalProperties": false
6717: +      },
6718: +      "SessionNextStepRecovered": {
6719: +        "type": "object",
6720: +        "properties": {
6721: +          "id": {
6722: +            "type": "string",
6723: +            "pattern": "^evt_"
6724: +          },
6725: +          "metadata": {
6726: +            "type": "object"
6727: +          },
6728: +          "type": {
6729: +            "type": "string",
6730: +            "enum": ["session.next.step.recovered"]
6731: +          },
6732: +          "durable": {
6733: +            "type": "object",
6734: +            "properties": {
6735: +              "aggregateID": {
6736: +                "type": "string"
6737: +              },
6738: +              "seq": {
6739: +                ...
gateway · 2026-09-15 23:24:16
result
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/src/session/projector.ts:428:          .delete(SessionMessageTable)
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/src/session/projector.ts:435:          .delete(SessionInputTable)
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/test/session-prompt.test.ts:424:      yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, sessionID)).run().pipe(Effect.orDie)
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/test/session-prompt.test.ts:426:        .delete(SessionMessageTable)
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/test/session-runner.test.ts:383:    yield* db.delete(SessionInputTable).where(eq(SessionInputTable.session_id, id)).run().pipe(Effect.orDie)
ab9408c81cc9916177641ae8e9f08624fc27b5b9:packages/core/test/session-runner.test.ts:384:    yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
gateway · 2026-09-15 23:24:23
result
   390	    yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
   391	    yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
   392	    // yield* events.project(SessionEvent.Retried, (event) => run(db, event))
   393	    yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
   394	    yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
   395	      db
   396	        .update(SessionTable)
   397	        .set({
   398	          revert: { ...event.data.revert, files: event.data.revert.files ? [...event.data.revert.files] : undefined },
   399	          time_updated: DateTime.toEpochMillis(event.data.timestamp),
   400	        })
   401	        .where(eq(SessionTable.id, event.data.sessionID))
   402	        .run()
   403	        .pipe(Effect.orDie, Effect.asVoid),
   404	    )
   405	    yield* events.project(SessionEvent.RevertEvent.Cleared, (event) =>
   406	      db
   407	        ...
gateway · 2026-09-15 23:24:37
result
     1	export * as EventV2 from "./event"
     2	
     3	import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect"
     4	import { Event } from "@opencode-ai/schema/event"
     5	import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
     6	import { and, asc, eq, gt, inArray } from "drizzle-orm"
     7	import { Database } from "./database/database"
     8	import { EventSequenceTable, EventTable } from "./event/sql"
     9	import { Location } from "./location"
    10	import { makeGlobalNode } from "./effect/app-node"
    11	import { isDeepStrictEqual } from "node:util"
    12	import { Durable } from "@opencode-ai/schema/durable-event-manifest"
    13	
    14	export const ID = Event.ID
    15	export type ID = import("@opencode-ai/schema/event").ID
    16	export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
    17	
    18	export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => ...
gateway · 2026-09-15 23:24:44
result
   350	                        }),
   351	                      { behavior: "immediate" },
   352	                    )
   353	                    .pipe(Effect.orDie)
   354	                  if (committed) {
   355	                    yield* Effect.forEach(
   356	                      pubsub.durable.get(committed.aggregateID) ?? [],
   357	                      (wake) => PubSub.publish(wake, undefined),
   358	                      { discard: true },
   359	                    )
   360	                  }
   361	                  return committed
   362	                }),
   363	              )
   364	            }
   365	          }
   366	        })
   367	      }
   368	
   369	      function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
   370	        return Effect.gen(function* () {
   371	          if (!definition?.durable && commit)
   372	            return yield* Effect.die(
   373	              new ...
gateway · 2026-09-15 23:26:02
result
     1	export * as SessionRunCoordinator from "./run-coordinator"
     2	
     3	import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
     4	
     5	/** Serializes execution for each key while allowing different keys to run concurrently. */
     6	export interface Coordinator<Key, E> {
     7	  /** Snapshots keys with an execution owned by this coordinator. */
     8	  readonly active: Effect.Effect<ReadonlySet<Key>>
     9	  /** Starts execution while idle or joins the active execution. */
    10	  readonly run: (key: Key) => Effect.Effect<void, E>
    11	  /** Registers one coalesced follow-up after newly recorded work. */
    12	  readonly wake: (key: Key) => Effect.Effect<void>
    13	  /** Stops active execution and waits for its cleanup. */
    14	  readonly interrupt: (key: Key) => Effect.Effect<void>
    15	}
    16	
    17	type Entry<E> = {
    18	  readonly done: Deferred.Deferred<void, E>
    19	  owner?: Fiber.Fiber<void, never>
    20	  pendingWake: ...
gateway · 2026-09-15 23:27:03
result
===== packages/core/src/session/execution/local.ts =====
   25   const leaseDuration = Duration.toMillis(SESSION_EXECUTION_LEASE_MS)
   26   const activeClaims = new Map<SessionSchema.ID, SessionExecutionClaim.Claim>()
   27   const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({
   28     drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
   29       const executionID = crypto.randomUUID()
   30       const now = yield* Clock.currentTimeMillis
   31       const claimed = yield* db
   32         .insert(SessionExecutionTable)
   33         .values({
   34           id: executionID,
   35           session_id: sessionID,
   36           owner_id: ownerID,
   37           phase: "ready",
   38           time_created: now,
   39           expires_at: now + leaseDuration,
   40         })
   41         .onConflictDoNothing()
   42         .returning({ id: SessionExecutionTable.id })
   43         .get()
   44         ...
gateway · 2026-09-15 23:27:25
result
--- 490-555 ---
  490           }),
  491         ),
  492       )
  493     })
  494 
  495     const run = Effect.fn("SessionRunner.run")(function* (input: {
  496       readonly sessionID: SessionSchema.ID
  497       readonly force: boolean
  498       readonly executionID: string
  499       readonly ownerID: string
  500       readonly recoveryInputID?: SessionMessage.ID
  501     }) {
  502       const claim = { sessionID: input.sessionID, executionID: input.executionID, ownerID: input.ownerID }
  503       const recovering = input.recoveryInputID !== undefined
  504       const hasSteer = recovering ? false : yield* SessionInput.hasPending(db, input.sessionID, "steer")
  505       const hasQueue = recovering || hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
  506       if (!recovering && !input.force && !hasSteer && !hasQueue) return
  507       yield* failInterruptedTools(input.sessionID, claim)
  508       let promotion: Promotion | undefined ...
gateway · 2026-09-15 23:27:57
result
  165     .where(eq(SessionInputTable.id, input.id))
  166     .get()
  167     .pipe(Effect.orDie)
  168   if (!row || row.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
  169   return row.promotedSeq === null && row.wakePending
  170 })
  171 
  172 export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
  173   db: DatabaseService,
  174   input: {
  175     readonly admittedSeq: number
  176     readonly id: SessionMessage.ID
  177     readonly sessionID: SessionSchema.ID
  178     readonly prompt: Prompt
  179     readonly delivery: Delivery
  180     readonly requestExecution: boolean
  181     readonly timeCreated: DateTime.Utc
  182   },
  183 ) {
  184   const message = yield* db
  185     .select({ id: SessionMessageTable.id })
  186     .from(SessionMessageTable)
  187     .where(eq(SessionMessageTable.id, input.id))
  188     .get()
  189     .pipe(Effect.orDie)
  190   if (message !== ...
gateway · 2026-09-15 23:28:56
result
  360       prompt: Effect.fn("V2Session.prompt")((input) =>
  361         Effect.uninterruptible(
  362           Effect.gen(function* () {
  363             yield* result.get(input.sessionID)
  364             const prompt = resolvePrompt(input.prompt)
  365             const messageID = input.id ?? SessionMessage.ID.create()
  366             const delivery = input.delivery ?? "steer"
  367             const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
  368             const admission = yield* SessionInput.admit(db, events, {
  369               id: messageID,
  370               sessionID: input.sessionID,
  371               prompt,
  372               delivery,
  373               requestExecution: input.resume !== false,
  374             }).pipe(
  375               Effect.catchDefect((defect) =>
  376                 defect instanceof SessionInput.LifecycleConflict
  377                   ? new PromptConflictError({ sessionID: input.sessionID, ...
gateway · 2026-09-15 23:29:14
result
  270         return yield* Effect.die(continueAfterCompaction(currentStep))
  271       const startSnapshot = yield* snapshots.capture()
  272       const publisher = createLLMEventPublisher(events, {
  273         sessionID: session.id,
  274         agent: agent.id,
  275         model: {
  276           id: ModelV2.ID.make(model.id),
  277           providerID: ProviderV2.ID.make(model.provider),
  278           ...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
  279         },
  280         snapshot: startSnapshot,
  281         startCommit: (assistantMessageID) => updateExecution({ assistant_message_id: assistantMessageID }),
  282         terminalCommit: () => updateExecution({ phase: "safe", assistant_message_id: null }),
  283         interruptedCommit: () => SessionExecutionClaim.clear(db, claim),
  284         eventCommit: () => updateExecution(),
  285       })
  286       const withPublication = Semaphore.makeUnsafe(1).withPermit
  287   ...
gateway · 2026-09-15 23:30:09

Findings

MUST-FIX — A crash before promotion can create extra recovery continuations and provider calls

SessionExecutionLocal inserts a ready claim before selecting or promoting any input (packages/core/src/session/execution/local.ts:31-63). Recovery converts every expired ready claim into Step.Recovered without checking whether ordinary or recovery input remains unpromoted (packages/core/src/session/recovery.ts:85-114). The projector then creates another queued continue and recovery row (packages/core/src/session/projector.ts:432-454).

Exploit sequence:

  1. Prompt U is admitted with durable wake intent.
  2. The drain inserts its ready claim.
  3. The server crashes before U is promoted.
  4. Recovery creates continuation C.
  5. Recovery wakes C, while the ordinary scan also wakes U (packages/core/src/session/recovery.ts:143-156,256,267-289).
  6. The recovery drain explicitly ignores ordinary pending inputs and promotes only C (packages/core/src/session/runner/llm.ts:503-527,218-224).
  7. The coalesced successor then promotes U (packages/core/src/session/run-coordinator.ts:57-68,86-97).

This produces one spurious provider call for C, followed by the intended call for U.

The second-restart variant is worse: if an existing recovery continuation is pending and its new ready claim crashes before exact promotion, recovery creates a second pending recovery row. With provider output delayed, both rows pass validation and both wakes are coalesced. They then run sequentially as duplicate continuations.

The supplied tests cover crashes after Step.Ended and Compaction.Ended, but not either claim-insert-to-promotion window. The reported exactly-once SEA test therefore does not prove this boundary. ...

gpt-5.6-sol · 2026-09-15 23:30:09 · ~1.1K tokens