Skip to content

Session recovery loses client environment and inherits another agent identity #51784

Open

Description

@gszep

Description

Local session environments set through session.environment are held only in a process-local map. After a server restart, automatic recovery resumes the durable execution but local shells fall back to the new server process environment. Starting that server from agent A can therefore run recovered agent B shells with A’s identity and workspace variables.

Confirmed in packages/core/src/session/environment.ts and shell.ts on v2 (39e1ce5). The live-process claim guard in #51215 does not cover a dead owner.

OpenCode version

2.0.15-based fork; same environment storage/fallback in current v2.

Plugins

Paseo bridge in the original server; no bridge in the recovering background service. The failure occurs in core shell environment selection.

Steps to reproduce

  1. Bind different client environments to two sessions in one directory.
  2. Interrupt their server while execution is active.
  3. Start a successor with a different agent identity in its process environment.
  4. Recover the sessions before their clients reconnect. Shells use the successor’s identity instead of waiting for their own binding.

Expected: recovery must wait for each previously client-bound environment to be reattached; never substitute the server environment. A durable presence marker can enforce this without storing environment values or secrets.

Operating System

macOS (also platform-independent core behavior)

Terminal

Paseo / OpenCode service

Activity

  1. github-actions commented on Sep 28, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. gszep commented on Sep 28, 2026

    @gszep
    Author

    Related to #38897, but this reproduction starts after explicit per-session environment binding already exists: recovery runs before that in-memory binding is restored. #51786 retains only the requirement and waits for reattachment. #44914 concerns MCP configuration interpolation; this patch is limited to local session shells and recovery.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I was able to reproduce this.

    Setup: the server ran with AGENT_ID=A in its own environment. A session was bound to AGENT_ID=B with session.environment.

    1. The session's first shell command logged AGENT_ID=B.
    2. I stopped the server while that command was still running and started a new one. The client did not reattach its environment.
    3. Recovery resumed the turn, and the next shell command logged AGENT_ID=A, the server's own environment instead of the session's.

    This happens on 2.0.15 and still happens on the latest dev (86ce19d). The script below reproduces it with a simulated model. I stopped the server normally rather than crashing it, which recovery handles the same way.

    As you pointed out, the per-session environment is only kept in memory, and shells fall back to the server's own environment when it's missing (packages/core/src/shell.ts). #51215 only skips claims held by a process that is still alive, so it doesn't cover this case. I've passed it on to the maintainers.

    Reproduction script (drive.ts)
    // Repro for #51784: after a server restart, a recovered session's shells use the
    // new server process environment instead of the environment bound via session.environment.
    // Run with: AGENT_ID=A OPENCODE_DRIVE_DB=restart.sqlite opencode-drive start --name r51784 --script drive.ts  (add --dev <checkout> for source)
    import { Effect } from "effect"
    import { readFileSync, existsSync, rmSync } from "node:fs"
    import { defineScript, Llm } from "opencode-drive"
    
    const LOG = "/tmp/opencode/agent-51784.log"
    const read = () => (existsSync(LOG) ? readFileSync(LOG, "utf8") : "")
    const waitFor = (needle: string, ms: number) =>
      Effect.gen(function* () {
        const end = Date.now() + ms
        while (Date.now() < end) {
          if (read().includes(needle)) return true
          yield* Effect.sleep(250)
        }
        return false
      })
    
    export default defineScript({
      launch: "manual",
    
      run: ({ server, llm }) =>
        Effect.gen(function* () {
          rmSync(LOG, { force: true })
          console.log("server process AGENT_ID =", process.env.AGENT_ID)
          const oc = yield* server.launch()
          const session = yield* oc.session.create({ title: "agent B session" })
          // Client binds its own environment (agent B) to the session.
          yield* oc.session.environment({
            sessionID: session.id,
            variables: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/root", AGENT_ID: "B" },
          })
          yield* llm.queue(
            Llm.toolCall({ index: 0, id: "call_1", name: "shell",
              input: { command: `echo "before-restart AGENT_ID=$AGENT_ID" >> ${LOG}; sleep 120`, description: "long" } }),
            Llm.finish("tool-calls"),
          )
          yield* oc.session.prompt({ sessionID: session.id, text: "run the long command" })
          if (!(yield* waitFor("before-restart", 20000))) throw new Error("first shell never ran")
          console.log("LOG before restart:\n" + read())
    
          // Server dies while the execution is active; successor starts (with AGENT_ID=A in its env).
          yield* server.kill()
          yield* llm.queue(
            Llm.toolCall({ index: 0, id: "call_2", name: "shell",
              input: { command: `echo "after-recovery AGENT_ID=$AGENT_ID" >> ${LOG}`, description: "check" } }),
            Llm.finish("tool-calls"),
          )
          yield* llm.queue(Llm.text("done"))
          yield* server.launch()
          // Client has NOT reattached its environment.
          const ok = yield* waitFor("after-recovery", 30000)
          console.log("LOG after recovery:\n" + read())
          if (!ok) throw new Error("recovered shell did not run")
        }),
    })

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions