Dashboard › opencode › Session 0hDs7WYqFiTf
0hDs7WYqFiTftFFj1Independent adversarial READ-ONLY security/integrity review of revised frozen V2 recovery candidate. Do not use lore/recall.
Immutable sources only:
ab9408c81cc9916177641ae8e9f08624fc27b5b9/tmp/opencode/session-recovery-final-v2.patch, SHA-256 b5878a90af5202832954778d14d2902e55ee9e51ff856592519177bafd392d90/home/byk/Code/opencode/packages/opencode/dist/server/opencode-server, SHA-256 9bbe18549e623e6ebe4ed6f75c5403381c35ae860ad2dc77698a469ab8a6c7e6Review 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. ...
b5878a90af5202832954778d14d2902e55ee9e51ff856592519177bafd392d90 /tmp/opencode/session-recovery-final-v2.patch 9bbe18549e623e6ebe4ed6f75c5403381c35ae860ad2dc77698a469ab8a6c7e6 /home/byk/Code/opencode/packages/opencode/dist/server/opencode-server commit
<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: ...
<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 ? ...
<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 ?? ...
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 ...
<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 ...
<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: ...
<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: + ...
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)
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 ...
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>) => ...
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 ...
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: ...
===== 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 ...
--- 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 ...
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 !== ...
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, ...
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 ...
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:
U is admitted with durable wake intent.ready claim.U is promoted.C.C, while the ordinary scan also wakes U (packages/core/src/session/recovery.ts:143-156,256,267-289).C (packages/core/src/session/runner/llm.ts:503-527,218-224).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. ...