From dc9098222dbfec804d64b71917bb625011beab02 Mon Sep 17 00:00:00 2001 From: Andre Agro Ferreira Date: Mon, 9 Mar 2026 22:37:38 +0000 Subject: [PATCH] fix(session): scope session list to current directory to prevent cross-worktree leakage When multiple git worktrees share the same root commit, they get the same project_id. This causes sessions from different worktrees to appear mixed in the session list. Similarly, the 'global' project (worktree '/') acts as a catch-all bucket, mixing sessions from unrelated directories. Changes: - Default the session list API route to filter by Instance.directory, ensuring sessions are scoped to the active working directory - Fix workspace_id filtering to include sessions with NULL workspace_id (pre-migration sessions) using OR condition instead of strict equality - Use explicit workspaceID input parameter in Session.list() instead of only reading from WorkspaceContext (the param was dead code before) - Add workspaceID query parameter to GET /session/ API route - Pass directory filter in CLI 'session list' command - Allow explicit workspaceID in Session.createNext() input - Add tests for directory scoping, NULL workspace_id handling, and explicit workspaceID in createNext Fixes #15678, relates to #16744, #16137 --- packages/opencode/src/cli/cmd/session.ts | 6 +- .../cmd/tui/component/dialog-session-list.tsx | 2 + .../opencode/src/server/routes/session.ts | 10 ++- packages/opencode/src/session/index.ts | 20 +++++- .../opencode/test/server/session-list.test.ts | 69 +++++++++++++++++++ 5 files changed, 102 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index 84840392a67..f80778cd50b 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -1,6 +1,7 @@ import type { Argv } from "yargs" import { cmd } from "./cmd" import { Session } from "../../session" +import { Instance } from "../../project/instance" import { bootstrap } from "../bootstrap" import { UI } from "../ui" import { Locale } from "../../util/locale" @@ -88,7 +89,10 @@ export const SessionListCommand = cmd({ }, handler: async (args) => { await bootstrap(process.cwd(), async () => { - const sessions = [...Session.list({ roots: true, limit: args.maxCount })] + // Scope session list to the current working directory to prevent + // cross-worktree session leakage when multiple worktrees share the + // same git root commit (and thus the same project_id). + const sessions = [...Session.list({ roots: true, limit: args.maxCount, directory: Instance.directory })] if (sessions.length === 0) { return diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index 775969bfcb3..1f40e45c571 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -26,6 +26,8 @@ export function DialogSessionList() { const [searchResults] = createResource(search, async (query) => { if (!query) return undefined + // The server-side session list already scopes to the current directory + // by default, so search results will also be directory-scoped. const result = await sdk.client.session.list({ search: query, limit: 30 }) return result.data ?? [] }) diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 12938aeaba0..43520105ef3 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -16,6 +16,7 @@ import { Log } from "../../util/log" import { PermissionNext } from "@/permission/next" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { Instance } from "../../project/instance" const log = Log.create({ service: "server" }) @@ -42,6 +43,7 @@ export const SessionRoutes = lazy(() => "query", z.object({ directory: z.string().optional().meta({ description: "Filter sessions by project directory" }), + workspaceID: z.string().optional().meta({ description: "Filter sessions by workspace ID" }), roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }), start: z.coerce .number() @@ -53,9 +55,15 @@ export const SessionRoutes = lazy(() => ), async (c) => { const query = c.req.valid("query") + // Default to the current instance directory when no directory filter is + // specified. This ensures sessions are scoped to the active working + // directory, preventing cross-worktree session leakage when multiple + // worktrees share the same git root commit (and thus the same project_id). + const directory = query.directory ?? Instance.directory const sessions: Session.Info[] = [] for await (const session of Session.list({ - directory: query.directory, + directory, + workspaceID: query.workspaceID, roots: query.roots, start: query.start, search: query.search, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index b117632051f..24f120152d7 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -293,6 +293,7 @@ export namespace Session { title?: string parentID?: string directory: string + workspaceID?: string permission?: PermissionNext.Ruleset }) { const result: Info = { @@ -301,7 +302,7 @@ export namespace Session { version: Installation.VERSION, projectID: Instance.project.id, directory: input.directory, - workspaceID: WorkspaceContext.workspaceID, + workspaceID: input.workspaceID ?? WorkspaceContext.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), permission: input.permission, @@ -541,9 +542,22 @@ export namespace Session { const project = Instance.project const conditions = [eq(SessionTable.project_id, project.id)] - if (WorkspaceContext.workspaceID) { - conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID)) + // Use explicit workspaceID parameter if provided, otherwise fall back to context. + // When filtering by workspace, also include sessions with NULL workspace_id + // (pre-migration sessions) so they are not silently hidden. + const wsID = input?.workspaceID ?? WorkspaceContext.workspaceID + if (wsID) { + conditions.push( + or( + eq(SessionTable.workspace_id, wsID), + isNull(SessionTable.workspace_id), + )!, + ) } + + // When multiple worktrees share the same project_id (e.g. git worktrees + // from the same repo), filter by directory to scope sessions to the + // current working directory. if (input?.directory) { conditions.push(eq(SessionTable.directory, input.directory)) } diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 675a89011f9..13146b486b9 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -3,6 +3,8 @@ import path from "path" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { Log } from "../../src/util/log" +import { Database, eq } from "../../src/storage/db" +import { SessionTable } from "../../src/session/session.sql" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) @@ -87,4 +89,71 @@ describe("Session.list", () => { }, }) }) + + test("includes sessions with NULL workspace_id when filtering by workspaceID", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // Create a session (will have workspace_id = undefined since no workspace context) + const session = await Session.create({ title: "pre-migration-session" }) + + // Verify the session has no workspace_id + expect(session.workspaceID).toBeUndefined() + + // When filtering by a workspaceID, sessions with NULL workspace_id + // should still be included (they are pre-migration sessions) + const sessions = [...Session.list({ workspaceID: "test-workspace-id" })] + const ids = sessions.map((s) => s.id) + + expect(ids).toContain(session.id) + }, + }) + }) + + test("directory filter prevents cross-worktree session leakage", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + // Create a session in the current directory + const localSession = await Session.create({ title: "local-session" }) + + // Create a session in a different directory (simulating a different worktree) + const otherDir = path.join(projectRoot, "..", "__other_worktree") + const otherSession = await Instance.provide({ + directory: otherDir, + fn: async () => Session.create({ title: "other-worktree-session" }), + }) + + // Both sessions share the same project_id (same git root) + expect(localSession.projectID).toBe(otherSession.projectID) + + // Without directory filter, both sessions appear + const allSessions = [...Session.list({})] + const allIds = allSessions.map((s) => s.id) + expect(allIds).toContain(localSession.id) + expect(allIds).toContain(otherSession.id) + + // With directory filter, only the local session appears + const scopedSessions = [...Session.list({ directory: projectRoot })] + const scopedIds = scopedSessions.map((s) => s.id) + expect(scopedIds).toContain(localSession.id) + expect(scopedIds).not.toContain(otherSession.id) + }, + }) + }) + + test("createNext accepts explicit workspaceID", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.createNext({ + directory: projectRoot, + workspaceID: "explicit-workspace-123", + title: "session-with-workspace", + }) + + expect(session.workspaceID).toBe("explicit-workspace-123") + }, + }) + }) })